shiftlabs Start free
Menu Client

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.

server.ts
// npm install shiftlabs
import { Shiftlabs } from "shiftlabs/client";

const sl = new Shiftlabs({ apiKey: process.env.SHIFTLABS_API_KEY });
  • apiKey

    An API key, for a server. Its role and capabilities decide what the client may do, as for any request.

  • chatToken

    A chat token, for a browser: one agent and one person. Pass the token, or a function that fetches one from your server.

  • baseUrl

    The API's origin. Default https://api.shiftlabs.org.

  • fetch

    The fetch to call, such as a stand-in in your tests. Default globalThis.fetch.

  • Pass apiKey or chatToken, not both.
  • The client needs only fetch and streams. It runs on Node 22.18 or later, Bun and in browsers.
  • Methods take a signal in their options to cancel. They return promises, except send and stream, which return a turn. A failure is a ShiftlabsError, apart from your signal's abort reason and anything your chatToken function 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.

server.ts
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 no decidedAt.

  • status

    {status, error?}: the conversation's status. Every status but working ends the turn.

  • Nothing is sent until the first send, which starts the conversation. Then chat.id is its id. Keep it, and pass it as conversation to agents.chat to carry on later.
  • person names your app's user, as End users describes. The key needs agent.chat_as. Without person, the key is the person.
  • Awaiting a turn gives {conversation, status, text, error?} once the conversation stops working. text is the text of the agent's last response. A turn also has catch and finally.
  • send while 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, approval or status: working event can come again, so key those by id.
  • Event types this version doesn't know are left out, so a newer API can't break a switch over event.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 one text event.
  • 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 after read() on a page that reloaded. Before the first send, both throw usage.

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.

server.ts
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);
  • ask waits until the conversation stops working, and while its tools wait for approval. Pass a signal to stop waiting.
  • schema takes 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.ask reads 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, ask throws invalid with what doesn't match.
  • A conversation that ends failed throws server. stopped at maxTurns and the budgets throw limit. The error's conversation is the conversation's id.

Workflows and runs

Start runs, read them and send them events.

server.ts
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 throws invalid with 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 idempotencyKey within 24 hours, and it returns the first run with replayed: true instead of starting another. Send one when you retry.
  • runs.get(id) reads {run, instance}: the run, and the engine's view of it, with its output once it completed. runs.list({ workflow, status, origin, limit, before }) reads a page, newest first. Pass its nextBefore as before for the next one. A page filtered by status can come back short, or empty, while nextBefore is still set: go on until it's null.
  • events.send(runId, name, data) sends the event a run's step.waitForEvent(name) waits for. Developer keys can.

Approvals

A key with approval.decide, which admins hold, lists and answers approval requests.

server.ts
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: pending by default, both agents' and workflows' requests unless kinds names one.
  • approvals.decide(id, decision, { note }) answers once. Answering again throws conflict.

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.

server.ts
// The app's key holds agent.chat_as.
const { token } = await sl.chatTokens.create({ agent: "billing", person: "user_ann", ttl: "1 hour" });
page.ts
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 }) throws config where a web page runs: under a window with a document (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 with slc_. Anything else throws config before a request, an API key above all.
  • chatTokens.create({ agent, person, ttl }) needs a key with agent.chat_as. ttl is 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.ask and their reads and streams work. Everything else throws forbidden without 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.

shellterminal
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 AgentNames and WorkflowPayloads of "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 additionalProperties allows them.
  • The built-in helper isn't in AgentNames: it talks only with members.
  • A key without workflow.view or agent.view leaves that part empty, so any name type-checks there, and types says which capability it lacks.
  • For an app outside the project's folder, write the file into the app with --out <dir>. Run types again 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.

server.ts
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":{…}}}
}
  • code is the kind of failure, with the CLI's error codes: auth, forbidden, not_found, conflict, invalid, limit, network, server, config and usage.
  • message names the request and the API's answer. hint says what to do next, when there is something to say, such as Try again in 60 seconds. after a 429.
  • status and response are the API's HTTP status and JSON body, when it answered. conversation is the conversation agents.ask started, when it failed after starting one.
  • A request your own signal aborted throws the abort's reason, not a ShiftlabsError. An error your chatToken function 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.

pythonapp.py
# 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"])
pythonapp.py
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, and sl.chat_tokens.create. There's no fetch or signal: cancel an AsyncShiftlabs call 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's AskResult(answer, conversation) and workflows.run's RunStarted(run_id, version_hash, replayed).
  • agents.chat returns the chat at once. With Shiftlabs, send sends before it returns, so it raises there. Iterate the turn for its events, or call result() for the TurnResult. With AsyncShiftlabs, send returns at once, inside a running event loop. Iterate the turn with async for, or await it, and that raises what went wrong.
  • schema is a JSON Schema dict, such as a pydantic model's model_json_schema(), as in a Python workflow's step.ask.
  • Failures raise ShiftlabsError, with the same code, message, hint, status, response and conversation. to_dict() gives the CLI's --json error.
  • chat_token takes a token, or a function that returns one, for code your end users can read, such as a desktop app. AsyncShiftlabs also 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_PROXY or HTTPS_PROXY, and doesn't follow redirects: a 3xx answer raises invalid.
  • npx shiftlabs types writes shiftlabs_types.pyi with your agent and workflow names, so pyright and mypy flag a misspelled one. Payloads aren't typed in Python.