Client
import { Shiftlabs } from "shiftlabs/client" gives your app the shiftlabs API: an API key on your server, a chat token in the browser. It comes with the shiftlabs npm package and has no dependencies.
Install
Install the shiftlabs package and create a client with an API key. On a server, read the key from the environment.
// npm install shiftlabs
import { Shiftlabs } from "shiftlabs/client";
const sl = new Shiftlabs({ apiKey: process.env.SHIFTLABS_API_KEY });
apiKeyAn API key, for a server. Its role and capabilities decide what the client may do, as for any request.
chatTokenA chat token, for a browser: one agent and one person. Pass the token, or a function that fetches one from your server.
baseUrlThe API's origin. Default
https://api.shiftlabs.org.fetchThe
fetchto call, such as a stand-in in your tests. DefaultglobalThis.fetch.
- Pass
apiKeyorchatToken, not both. - The client needs only
fetchand streams. It runs on Node 22.18 or later, Bun and in browsers. - Methods take a
signalin their options to cancel. They return promises, exceptsendandstream, which return a turn. A failure is aShiftlabsError, apart from yoursignal's abort reason and anything yourchatTokenfunction throws.
Chat
sl.agents.chat(agent, { person }) is a conversation with an agent as one of your app's users. send sends a message and streams the agent's turn as it happens: its text as it is written, tool calls and their results, approvals and status changes.
const ann = await sl.agents.chat("billing", { person: "user_ann" });
for await (const event of ann.send("Where is my refund for in_123?")) {
if (event.type === "text") process.stdout.write(event.delta);
if (event.type === "approval") console.log(`\n${event.tool} waits for an approver`);
}
// Or await the turn for the agent's answer.
const bob = await sl.agents.chat("billing", { person: "user_bob" });
const { status, text } = await bob.send("What do I owe this month?");
text{delta}: the next piece of the agent's answer.user·approval_update{text, at}: a message in, or the platform's approval update.tool_call·tool_result{id, name, arguments, at}, then{id, name, isError, text, at}. A chat token gets neither.approval{id, tool, status, requestedAt, expiresAt, decidedAt}: a tool call waits for an approver, or was answered. A chat token's have nodecidedAt.status{status, error?}: the conversation's status. Every status butworkingends the turn.
- Nothing is sent until the first
send, which starts the conversation. Thenchat.idis its id. Keep it, and pass it asconversationtoagents.chatto carry on later. personnames your app's user, as End users describes. The key needsagent.chat_as. Withoutperson, the key is the person.- Awaiting a turn gives
{conversation, status, text, error?}once the conversation stops working.textis the text of the agent's last response. A turn also hascatchandfinally. sendwhile the agent still works on an earlier message waits for that turn to end, then sends.- The turn streams over the conversation's Server-Sent Events. If the connection drops, goes quiet for 60 seconds (three missed heartbeats), or the stream ends after 5 minutes, the client reconnects with the last event's id. Text isn't repeated, but a
tool_call,approvalorstatus: workingevent can come again, so key those byid. - Event types this version doesn't know are left out, so a newer API can't break a
switchoverevent.type. - Where streams aren't available yet (
501), the client reads the conversation every 1.5 seconds instead. Each new message then comes whole, as onetextevent. chat.read()reads the conversation: its status, transcript and approvals.chat.stream()follows it from the last event the chat saw, for example after an approval, or afterread()on a page that reloaded. Before the firstsend, both throwusage.
Ask for an answer
sl.agents.ask(agent, message) asks in a new conversation and returns {answer, conversation}: the agent's answer and the conversation's id. With schema, answer is JSON that matches it, typed by the schema, as step.ask returns in a workflow.
import * as z from "zod";
const Verdict = z.object({ duplicate: z.boolean(), reason: z.string() });
const { answer } = await sl.agents.ask("billing", "Is invoice in_123 a duplicate?", { person: "user_ann", schema: Verdict });
if (answer.duplicate) console.log(answer.reason);
askwaits until the conversation stops working, and while its tools wait for approval. Pass asignalto stop waiting.schematakes a Standard JSON Schema, such as zod 4.2+, ArkType 2.1.28+ or Valibot through@valibot/to-json-schema, or plain JSON Schema. With plain JSON Schema, name the answer's type yourself:ask<Verdict>(…).- The message tells the agent to answer with JSON that matches the schema, and carries the schema. Both count toward the message's 4,000 characters.
- The answer is read as
step.askreads it: as JSON, as the JSON between its first{or[and its last}or], or as the text itself. It's checked against the JSON Schema and returned as the agent wrote it, so a schema's transforms don't run. - An answer that doesn't match gets one more message that says what is wrong. That's a second message, and it counts toward the limits. If that answer doesn't match either,
askthrowsinvalidwith what doesn't match. - A conversation that ends
failedthrowsserver.stopped at maxTurnsand the budgets throwlimit. The error'sconversationis the conversation's id.
Workflows and runs
Start runs, read them and send them events.
const invoiceId = "in_123";
const { runId } = await sl.workflows.run("stripe-refund", { invoiceId }, { idempotencyKey: `refund-${invoiceId}` });
const { run, instance } = await sl.runs.get(runId);
if (run.status === "completed") console.log(instance.output);
await sl.events.send(runId, "signed", { by: "ann@example.com" });
const { runs, nextBefore } = await sl.runs.list({ workflow: "stripe-refund", status: "failed" });
workflows.run(name, payload, { idempotencyKey })starts a run of the current version. A payload that fails the workflow's payload schema throwsinvalidwith what doesn't match. Leave the payload out for a workflow whose payload type takes{}, such as one without a payload schema, and the client sends{}.- Run it again with the same
idempotencyKeywithin 24 hours, and it returns the first run withreplayed: trueinstead of starting another. Send one when you retry. runs.get(id)reads{run, instance}: the run, and the engine's view of it, with itsoutputonce it completed.runs.list({ workflow, status, origin, limit, before })reads a page, newest first. Pass itsnextBeforeasbeforefor the next one. A page filtered bystatuscan come back short, or empty, whilenextBeforeis still set: go on until it'snull.events.send(runId, name, data)sends the event a run'sstep.waitForEvent(name)waits for. Developer keys can.
Approvals
A key with approval.decide, which admins hold, lists and answers approval requests.
const waiting = await sl.approvals.list();
for (const request of waiting) {
if (request.kind === "agent" && request.tool === "stripe-refund") await sl.approvals.decide(request.id, "approve");
}
await sl.approvals.decide("ap_123", "deny", { note: "Not this month." });
approvals.list({ status, kinds })lists the requests this key may answer:pendingby default, both agents' and workflows' requests unlesskindsnames one.approvals.decide(id, decision, { note })answers once. Answering again throwsconflict.
Browsers
An API key never goes in a browser. Your server mints a chat token for one person and one agent, and the page chats with it.
// The app's key holds agent.chat_as.
const { token } = await sl.chatTokens.create({ agent: "billing", person: "user_ann", ttl: "1 hour" });
import { Shiftlabs } from "shiftlabs/client";
// Fetched again from your server when it expires.
const sl = new Shiftlabs({ chatToken: () => fetch("/chat-token").then((r) => r.text()) });
const chat = await sl.agents.chat("billing");
const answer = document.querySelector("#answer")!;
for await (const event of chat.send("Where is my refund?")) {
if (event.type === "text") answer.textContent += event.delta;
}
new Shiftlabs({ apiKey })throwsconfigwhere a web page runs: under awindowwith adocument(browsers, jsdom, Electron's renderer) and in web workers. Servers, Cloudflare Workers included, take a key.- A
chatToken, or what its function returns, must be a chat token, which starts withslc_. Anything else throwsconfigbefore a request, an API key above all. chatTokens.create({ agent, person, ttl })needs a key withagent.chat_as.ttlis milliseconds or a duration such as"15 minutes", from 1 minute to 1 hour, and an hour by default.- A token chats with its agent as its person, so the page passes no
person.agents.chat,agents.askand their reads and streams work. Everything else throwsforbiddenwithout sending a request, since a browser couldn't read the API's answer. - When the API answers that the token expired, a client given a function calls it once for a new token and sends the request again. Given the token itself, it throws
auth. - A token reads what its person may see: no tool calls or results, models or cost. See Browsers.
Typed names
shiftlabs types writes the workspace's agent names and each workflow's payload type into shiftlabs-env.d.ts. Then a misspelt agent or a wrong payload fails tsc.
npx shiftlabs types --out src
# Wrote src/shiftlabs-env.d.ts and src/shiftlabs_types.pyi: 1 secret, 2 vars, 1 agent and 3 workflows.
npx tsc --noEmit
# src/refund.ts(4,52): error TS2322: Type 'number' is not assignable to type 'string'.
- The file fills
AgentNamesandWorkflowPayloadsof"shiftlabs/client". Until they're filled, any name and payload type-check. - A payload's type comes from the workflow's payload schema. A workflow without one takes any payload. What TypeScript can't check, such as formats, lengths and patterns, the API still does.
- An object lists the properties its schema names, plus any others when
additionalPropertiesallows them. - The built-in helper isn't in
AgentNames: it talks only with members. - A key without
workflow.vieworagent.viewleaves that part empty, so any name type-checks there, andtypessays which capability it lacks. - For an app outside the project's folder, write the file into the app with
--out <dir>. Runtypesagain after deploying a new agent or workflow, or changing a payload schema.
Errors
Every failure throws a ShiftlabsError. Its toJSON() is the error the CLI prints with --json.
import { ShiftlabsError } from "shiftlabs/client";
try {
await sl.workflows.run("stripe-refund", { invoiceId: 123 });
} catch (err) {
if (!(err instanceof ShiftlabsError)) throw err;
console.log(err.code, err.status, err.hint); // invalid 400 undefined
console.log(JSON.stringify(err)); // {"error":{"code":"invalid","message":"POST /v1/workflows/stripe-refund/runs -> 400: payload does not match the schema: …","status":400,"response":{…}}}
}
codeis the kind of failure, with the CLI's error codes:auth,forbidden,not_found,conflict,invalid,limit,network,server,configandusage.messagenames the request and the API's answer.hintsays what to do next, when there is something to say, such asTry again in 60 seconds.after a429.statusandresponseare the API's HTTP status and JSON body, when it answered.conversationis the conversationagents.askstarted, when it failed after starting one.- A request your own
signalaborted throws the abort's reason, not aShiftlabsError. An error yourchatTokenfunction throws reaches you as it is.
Python
shiftlabs.client is the same client for Python apps, in the shiftlabs package on PyPI. Shiftlabs waits for each call; AsyncShiftlabs has the same members for asyncio. It needs Python 3.11 or later and nothing outside the standard library.
# pip install shiftlabs
import os
from shiftlabs.client import Shiftlabs
sl = Shiftlabs(api_key=os.environ["SHIFTLABS_API_KEY"])
ann = sl.agents.chat("billing", person="user_ann")
for event in ann.send("Where is my refund for in_123?"):
if event["type"] == "text":
print(event["delta"], end="")
verdict = sl.agents.ask("billing", "Is invoice in_123 a duplicate?", person="user_bob", schema={"type": "boolean"})
invoice_id = "in_123"
started = sl.workflows.run("stripe-refund", {"invoiceId": invoice_id}, idempotency_key=f"refund-{invoice_id}")
print(verdict.answer, sl.runs.get(started.run_id)["run"]["status"])
import asyncio
import os
from shiftlabs.client import AsyncShiftlabs
async def main() -> None:
sl = AsyncShiftlabs(api_key=os.environ["SHIFTLABS_API_KEY"])
chat = sl.agents.chat("billing", person="user_ann")
async for event in chat.send("Where is my refund?"):
if event["type"] == "text":
print(event["delta"], end="")
result = await chat.send("Thanks. When will it arrive?")
print(result.status, result.text)
asyncio.run(main())
- Options are snake case:
api_key,chat_token,base_url,idempotency_key, andsl.chat_tokens.create. There's nofetchorsignal: cancel anAsyncShiftlabscall by cancelling its task. - What the API sends keeps the API's keys. Events, conversations, runs and approvals are dicts, typed as
TypedDicts:event["type"],sl.runs.get(run_id)["run"]["status"]. - What the client makes is a frozen dataclass: a turn's
TurnResult(conversation, status, text, error),ask'sAskResult(answer, conversation)andworkflows.run'sRunStarted(run_id, version_hash, replayed). agents.chatreturns the chat at once. WithShiftlabs,sendsends before it returns, so it raises there. Iterate the turn for its events, or callresult()for theTurnResult. WithAsyncShiftlabs,sendreturns at once, inside a running event loop. Iterate the turn withasync for, or await it, and that raises what went wrong.schemais a JSON Schema dict, such as a pydantic model'smodel_json_schema(), as in a Python workflow'sstep.ask.- Failures raise
ShiftlabsError, with the samecode,message,hint,status,responseandconversation.to_dict()gives the CLI's--jsonerror. chat_tokentakes a token, or a function that returns one, for code your end users can read, such as a desktop app.AsyncShiftlabsalso takes an async function.- A request that waits 60 seconds for the API to answer, or to send more, fails with
network. A stream reconnects instead, as above. - The client doesn't read
HTTP_PROXYorHTTPS_PROXY, and doesn't follow redirects: a3xxanswer raisesinvalid. npx shiftlabs typeswritesshiftlabs_types.pyiwith your agent and workflow names, so pyright and mypy flag a misspelled one. Payloads aren't typed in Python.