shiftlabs Start free
Menu Agents

Agents

An agent is instructions, a model and tools. A tool is one of your workflows, or a function in the agent's own code.

Define an agent

Deploy an agent with PUT /v1/agents/:agent, from a project with shiftlabs deploy, or from a template on the dashboard's Agents screen. Each deploy adds a version and makes it current.

Agents are on in a new workspace. An admin can turn them off.

shellcurl
# Deploy the agent (developer key). Answers {"name", "version"}.
# Deploy each tool's workflow first.
curl -X PUT https://api.shiftlabs.org/v1/agents/billing \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d '{
    "title": "Billing assistant",
    "instructions": "Answer questions about invoices. Look an invoice up before you answer.",
    "model": "@cf/zai-org/glm-4.7-flash",
    "tools": [{
      "workflow": "lookup-invoice",
      "description": "Look up an invoice by its id.",
      "parameters": {
        "type": "object",
        "properties": {"invoice": {"type": "string"}},
        "required": ["invoice"]
      }
    }]
  }'
  • title

    1 to 80 characters, shown in the dashboard.

  • description

    What the agent does, up to 300 characters, shown in the dashboard's list of agents. Optional.

  • instructions

    What the agent is for and how it works, 1 to 8,000 characters. In a project, file("./x.md") keeps them in a file.

  • model

    A model id from Models. Default @cf/zai-org/glm-4.7-flash.

  • fallback

    Up to 3 more model ids, tried in order when a request to model fails before it answers. None may be of a lower tier than model. Left out, the platform picks one; [] means none.

  • maxTurns

    Model turns one message may take, 1 to 20. Default 6.

  • budget

    {conversationUsd, monthlyUsd}: what the agent may spend. See Budgets.

  • access

    {role, members}: who may message the agent. See Access.

  • welcome

    Shown in the dashboard's chat before the first message, up to 500 characters. Optional.

  • suggestions

    Up to 4 first messages, each up to 200 characters, offered in the dashboard's chat. Choosing one sends it.

  • tools

    Up to 16 workflow tools, counting the tools in the agent's code.

  • code

    The codeHash of the agent's code. Every tool in the code joins the agent.

shiftlabs.config.ts
import { agent, file, project, workflowTool } from "shiftlabs";

export default project({
  workflows: { "stripe-refund": "./workflows/stripe-refund.ts" },
  agents: {
    "invoice-assistant": agent({
      title: "Invoice assistant",
      description: "Answers billing questions and issues refunds.",
      instructions: file("./agents/invoice-assistant.md"),
      model: "@cf/zai-org/glm-4.7-flash",
      tools: [workflowTool("stripe-refund", { description: "Refund an invoice.", approval: "always" })],
      budget: { conversationUsd: 0.5, monthlyUsd: 20 },
      access: { role: "developer", members: ["ana@acme.com"] },
      welcome: "Ask me about an invoice.",
      suggestions: ["Why was I charged twice?", "Refund invoice in_123"],
    }),
  },
});

An agent's name is 2 to 48 lowercase letters, digits and hyphens, and starts with a letter. shiftlabs is taken by the helper.

A bad field, or a tool whose workflow doesn't exist, returns 400 {"error": "invalid definition", "detail"}. For a model id agents can't use, the detail lists the ones they can. A model your plan doesn't include returns 403 {"error": "plan", "detail"}. A new agent past your plan's limit returns 403 {"error": "quota exceeded", "limit": "agents", "max"}.

Models

Free workspaces use the small models. Pro workspaces use every model below. A model's tier is its answer quality, from 1 to 5. Prices are in US dollars per million tokens. Each response's tokens, at the prices of the model that answered, count toward the agent's budgets and the workspace's model allowance.

Small models, on Free and Pro
ModelTierInputOutput
@cf/google/gemma-4-26b-a4b-it4$0.10$0.30
@cf/ibm-granite/granite-4.0-h-micro1$0.017$0.112
@cf/meta/llama-3.1-8b-instruct-fp8-fast2$0.045$0.384
@cf/meta/llama-3.2-1b-instruct1$0.027$0.201
@cf/meta/llama-3.2-3b-instruct1$0.051$0.335
@cf/mistral/mistral-7b-instruct-v0.11$0.11$0.19
@cf/qwen/qwen3-30b-a3b-fp84$0.051$0.335
@cf/zai-org/glm-4.7-flash4$0.06$0.40
Every other model, on Pro
ModelTierInputOutput
@cf/aisingapore/gemma-sea-lion-v4-27b-it3$0.351$0.555
@cf/deepseek-ai/deepseek-r1-distill-qwen-32b3$0.497$4.881
@cf/deepseek-ai/deepseek-v4-flash-0731 cached input $0.0145$0.44$1.32
@cf/deepseek-ai/deepseek-v4-pro-0813 cached input $0.0445$1.32$3.96
@cf/deepseek-ai/deepseek-v4.1-flash-1017 cached input $0.0065$0.30$1.20
@cf/google/gemma-3-12b-it2$0.345$0.556
@cf/meta/llama-2-7b-chat-fp161$0.556$6.667
@cf/meta/llama-3-8b-instruct2$0.282$0.827
@cf/meta/llama-3-8b-instruct-awq2$0.123$0.266
@cf/meta/llama-3.1-70b-instruct-fp8-fast3$0.293$2.253
@cf/meta/llama-3.1-8b-instruct2$0.282$0.827
@cf/meta/llama-3.1-8b-instruct-awq2$0.123$0.266
@cf/meta/llama-3.1-8b-instruct-fp82$0.152$0.287
@cf/meta/llama-3.2-11b-vision-instruct2$0.049$0.676
@cf/meta/llama-3.3-70b-instruct-fp8-fast3$0.293$2.253
@cf/meta/llama-4-scout-17b-16e-instruct3$0.27$0.85
@cf/mistralai/mistral-small-3.1-24b-instruct3$0.351$0.555
@cf/moonshotai/kimi-k2.5 cached input $0.105$0.60$3.00
@cf/moonshotai/kimi-k2.6 cached input $0.165$0.95$4.00
@cf/moonshotai/kimi-k2.7-code cached input $0.194$0.95$4.00
@cf/nvidia/nemotron-3-120b-a12b4$0.50$1.50
@cf/openai/gpt-oss-120b4$0.35$0.75
@cf/openai/gpt-oss-20b4$0.20$0.30
@cf/qwen/qwen2.5-coder-32b-instruct3$0.66$1.00
@cf/qwen/qwen3.8-27b5$0.45$3.20
@cf/qwen/qwq-32b3$0.66$1.00
@cf/zai-org/glm-5.2 cached input $0.265$1.40$4.40
@cf/zai-org/glm-5.3 cached input $0.265$1.40$4.40
@cf/zai-org/glm-5.3-flash cached input $0.035$0.15$0.50
  • Any other model id returns 400.
  • A fallback must be the same tier as model or higher, so a failing model never lowers the quality of answers. A fallback of a lower tier returns 400.
  • Without fallback, the platform picks one: of the models at the lowest tier at or above that of model, the one closest in price. For a small model it picks a small model.
  • When a fallback answers, the conversation's model item names it with "fallback": true, and the dashboard's trace marks it.
  • In a Free workspace, deploying, releasing or messaging an agent on a Pro model returns 403 {"error": "plan", "detail"}. An agent on a Pro model stops taking messages when its workspace moves back to Free.

Workflow tools

A workflow tool runs one of your workflows. The model's arguments, checked against parameters, are the run's payload. In a project, workflowTool(workflow, {...}) defines one.

shiftlabs.config.ts
import { agent, project, workflowTool } from "shiftlabs";
import * as z from "zod";

export default project({
  workflows: { "lookup-invoice": "./workflows/lookup-invoice.ts" },
  agents: {
    billing: agent({
      title: "Billing assistant",
      instructions: "Answer questions about invoices. Look an invoice up before you answer.",
      model: "@cf/zai-org/glm-4.7-flash",
      tools: [
        workflowTool("lookup-invoice", {
          description: "Look up an invoice by its id.",
          parameters: z.object({ invoice: z.string() }),
        }),
      ],
    }),
  },
});
  • workflow

    The workflow's name. Deploy the workflow before the agent.

  • description

    When to use the tool, 1 to 1,024 characters. The model chooses tools by it.

  • parameters

    A JSON Schema with "type": "object", up to 8 KB. Left out, it is the workflow's payload schema as of the agent's deploy, or any object when the workflow has none. A payload schema that isn't an object schema, or is over 8 KB, can't stand in: that deploy returns 400 and asks for parameters. In shiftlabs.config.ts, any Standard JSON Schema too, such as a zod 4.2+ schema: shiftlabs deploy sends its JSON Schema.

  • approval

    "always" holds every call until an admin approves it. {"approvers": ["role:admin", "ana@acme.com"]} holds it for those roles and members only: up to 20, each role:admin, role:manager or the email of a member who is an admin when you deploy. A developer never approves. See Who approves. Default "never". The stored definition reads "approval": "always" with the tool's approvers beside it, and a deploy takes that shape too.

  • waitSeconds

    How long a call waits for the run's output, 5 to 300. Default 60.

  • A call starts a run, as run now does. It counts toward runs per 24 hours and runs at once, and it is refused past the spending limit.
  • The run is listed with your other runs, with origin agent and origin_ref <agent>/<conversation id>.
  • The model reads Run <id> completed. Output: <JSON>, or the run's error. A run still going after waitSeconds goes on, but its output doesn't reach the conversation.
  • A readable secret's value in a result is replaced by [redacted] before the model or the transcript sees it. A result is cut at 16,000 characters.
  • An API key scoped to some workflows can't give an agent tools outside them. That deploy returns 403.
  • A tool that took its workflow's payload schema keeps it until the agent is deployed again. shiftlabs deploy releases a project's workflows and agents together, so they stay in step.

Code tools

A code tool is a function in the agent's own TypeScript, defined with its description and parameters by tool({...}, fn). It has no network, no environment and no secrets. It keeps a SQLite database for each person who talks to the agent.

agents/notes.tools.ts
import { tool, tools } from "shiftlabs";

export default tools({
  "save-note": tool(
    {
      description: "Save a note.",
      parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
    },
    ({ text }: { text: string }, { sql }) => {
      sql.exec("CREATE TABLE IF NOT EXISTS notes (at INTEGER, text TEXT)");
      sql.exec("INSERT INTO notes (at, text) VALUES (?, ?)", Date.now(), text);
      return { saved: true };
    },
  ),
  "list-notes": tool({ description: "List the latest notes." }, (_args, { sql }) => {
    sql.exec("CREATE TABLE IF NOT EXISTS notes (at INTEGER, text TEXT)");
    return sql.exec("SELECT at, text FROM notes ORDER BY at DESC LIMIT 20").toArray();
  }),
});
shiftlabs.config.ts
import { agent, project } from "shiftlabs";

// shiftlabs deploy uploads the code, then releases the agent with it.
export default project({
  agents: {
    notes: agent({
      title: "Notes",
      instructions: "Save the notes people give you, and read them back when asked.",
      model: "@cf/zai-org/glm-4.7-flash",
      code: "./agents/notes.tools.ts",
    }),
  },
});
shellcurl
# Upload the code (developer key). Answers {"codeHash", "tools", "sizeBytes", "warnings"}.
curl -X PUT https://api.shiftlabs.org/v1/agents/notes/code \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" --data-binary @agents/notes.tools.ts

# Name the hash in the agent's next version. Every tool in the code joins it.
curl -X PUT https://api.shiftlabs.org/v1/agents/notes \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d '{
    "title": "Notes",
    "instructions": "Save the notes people give you, and read them back when asked.",
    "model": "@cf/zai-org/glm-4.7-flash",
    "code": "<codeHash>"
  }'
  • description

    When to use the tool, 1 to 1,024 characters. The model chooses tools by it. Required.

  • parameters

    A JSON Schema with "type": "object", up to 8 KB. Default: any object. The upload converts a Standard JSON Schema to JSON Schema.

  • waitSeconds

    How long a call waits for the function, 5 to 300. Default 60.

  • The code is bundled and scanned like a workflow, 512 KiB at most, and a new upload counts toward new versions per 24 hours. Its default export must be tools({...}), with a tool(...) under each name. Names are lowercase letters, digits and hyphens.
  • The upload reads each tool's description and parameters. A tool with no description or no function, a bad name or parameters that aren't an object schema returns 400 {"error": "invalid tool", "detail"}, and detail names the tool. Descriptions and parameters together are at most 256 KiB.
  • PUT /v1/agents/:agent and a release that name code count toward the 20 deploys a minute, as the upload does. See Rate limits.
  • Nothing changes until a version names the hash in code. Then every tool in the code joins the agent, after its workflow tools. A name can't be both a workflow tool and a code tool. An upload that no version names is dropped after a day.
  • A function gets the model's arguments and {sql, conversation, log}. sql.exec(query, ...bindings) returns a cursor with toArray(), one() and rowsWritten. The model reads the return value as JSON.
  • Each person's database is shared by all their conversations with the agent, and a workflow that asks the agent has one for all its runs. Each end user of your app has their own. It is kept until the workspace is deleted.
  • Each person's code runs apart from everyone else's. A value kept outside a function lasts only while the code is loaded. Keep data in sql.
  • A code tool never waits for approval. Calls to other services belong in workflow tools.
  • A person's calls run one at a time. A call still waiting for an earlier one when its waitSeconds pass fails without running.
  • A call gets 2,000 ms of CPU and the tool's waitSeconds. Past either, that call fails and the code restarts. A call cut off by a platform restart is not run again.

Conversations

Anyone the agent's access includes can talk to it. A message starts a conversation, and the agent answers in the background. Read the conversation, or stream it to get the answer as it is written.

shellcurl
# Start a conversation (any role). Answers 202 {"conversation", "status": "working"}.
curl -X POST https://api.shiftlabs.org/v1/agents/billing/conversations \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d '{"text": "What is the status of invoice in_1QxYzExample?"}'

# Read it until its status is no longer working.
curl https://api.shiftlabs.org/v1/agents/billing/conversations/<conversation> \
  -H "authorization: Bearer $SHIFTLABS_API_KEY"

# Send the next message. Answers 202.
curl -X POST https://api.shiftlabs.org/v1/agents/billing/conversations/<conversation>/messages \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d '{"text": "When was it paid?"}'
  • working

    The agent is calling models and tools.

  • answered

    The agent replied. Send the next message.

  • waiting for approval

    A tool call waits for an approver. See Approvals.

  • stopped at maxTurns

    The model kept asking for tools past maxTurns. Send another message to go on.

  • budget reached

    The conversation spent its budget.conversationUsd. The next message returns 409 {"error": "budget reached"}. Start a new conversation.

  • monthly budget reached

    The agent spent its budget.monthlyUsd for this UTC month. See Budgets.

  • allowance reached

    The workspace's model allowance ran out during the turn.

  • failed

    A model request failed, or sent nothing for 120 seconds. The transcript's error item says why. Send the next message to try again.

  • A message is 1 to 4,000 characters. It can't start with Approval update:, which marks approval answers.
  • A conversation keeps the agent version it started on. Only the person or API key that started it can add to it.
  • A workflow's step.ask starts a conversation the workflow owns. Its person is workflow:<name>, its personLabel is workflow <name>, and its run is the run that asked. Nobody can add to it.
  • A model request that sends nothing for 120 seconds, before or during its answer, ends the turn as failed, with an error item saying the model did not respond for 120 s. It isn't retried, and its unfinished answer costs nothing. An answer that keeps arriving is never cut off.
  • A turn waits for each tool call at most the tool's waitSeconds. Past it, a workflow tool's run goes on without the conversation, and a code tool's call fails. A helper tool's call fails after 30 seconds, and the model reads <tool> did not finish within 30 s.
  • GET …/conversations lists your own, newest first, 50 at most, and with ?person= an end user's. Developers and admins can list everyone's with ?all=1 and read any conversation. Lists leave out the conversations workflows started: ?workflows=1 lists those instead, and ?run=<id> one run's. Reading other people's conversations is written to the audit log. A viewer gets 403 for ?all=1, ?workflows=1 or ?run= and 404 for another person's conversation.
  • A conversation reads {id, agent, person, personLabel, run, version, status, startedAt, lastAt, costUsd, busy, turns, maxTurns, budgetUsd, items, approvals, lastEventId}. run is null unless a workflow owns it. budgetUsd is its version's budget.conversationUsd. items is the transcript: user, assistant, model (the model that gave each response, whether it was a fallback, its tokens and cost), tool_call, tool_result, approval_update and error. lastEventId is where its stream stands after items.
  • Conversations are kept until the workspace is deleted. Model requests and answers are also logged with no time limit, and those logs aren't erased with the workspace. See Privacy.

End users

An app built on an agent calls it with one API key for all its users. Give that key agent.chat_as, and it names the user each request is for with person. Each person gets their own conversations, their own code tool database and their own message limit.

shellcurl
# Create the app's key (admin key). agent.chat_as is in no role, so list it.
curl -X POST https://api.shiftlabs.org/v1/api-keys \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d '{"name": "billing-app", "role": "developer", "capabilities": ["agent.chat", "agent.chat_as"], "scope": {"agents": ["billing"]}}'

# Start a conversation for user_123. Answers 202 {"conversation", "status": "working"}.
curl -X POST https://api.shiftlabs.org/v1/agents/billing/conversations \
  -H "authorization: Bearer $APP_KEY" -H "content-type: application/json" \
  -d '{"text": "Where is my refund?", "person": "user_123"}'

# Read it, list user_123's conversations, and send the next message as user_123.
curl "https://api.shiftlabs.org/v1/agents/billing/conversations/<conversation>?person=user_123" \
  -H "authorization: Bearer $APP_KEY"
curl "https://api.shiftlabs.org/v1/agents/billing/conversations?person=user_123" \
  -H "authorization: Bearer $APP_KEY"
curl -X POST https://api.shiftlabs.org/v1/agents/billing/conversations/<conversation>/messages \
  -H "authorization: Bearer $APP_KEY" -H "content-type: application/json" \
  -d '{"text": "Thanks!", "person": "user_123"}'
  • agent.chat_as is in no role. An admin gives it to a developer or admin key by listing it in capabilities, or on the API keys screen under Limit this key → Not in any role. Members signed in to the dashboard never hold it.
  • person is 1 to 128 letters, digits and _ - . @ : | + =, such as your app's user id. Send it in the body of a POST and as ?person= on a GET. A key without agent.chat_as gets 403 with "capability": "agent.chat_as", and a bad name gets 400.
  • The conversation's person is user:<person>, and its personLabel is the name you sent. Every key in the workspace that names the same person reaches the same conversations, so a new key carries on where an old one stopped.
  • The dashboard and approval notices show an end user as <person> (end user), so ceo@acme.com from your app isn't taken for the member ceo@acme.com.
  • Without person, the key is the person, as before. As user_123, a key can't read or add to its own conversations, a member's, a workflow's or another person's: they return 404.
  • Each person has their own limit of 10 messages a minute, and every person's messages count toward the workspace's limits. The agent's access is checked against the key.
  • The helper talks only with members and keys, so person on it returns 400.
  • ?person= can't be combined with ?all=1, ?workflows=1 or ?run=: that's 403 without conversation.read_others and 400 with it. Developers and admins see every person's conversations with ?all=1.

Browsers

An API key never goes in a browser. Your server mints a chat token for one person and one agent, and the browser chats with it, from any site. A token lasts at most an hour. With shiftlabs/client, the server mints it with chatTokens.create and the page chats with new Shiftlabs({ chatToken }).

shellcurl
# With the app's key, which holds agent.chat_as. Answers 201 {"token", "agent", "person", "expiresAt"}.
curl -X POST https://api.shiftlabs.org/v1/chat-tokens \
  -H "authorization: Bearer $APP_KEY" -H "content-type: application/json" \
  -d '{"agent": "billing", "person": "user_123", "ttl": "15 minutes"}'
chat.ts
const conversations = "https://api.shiftlabs.org/v1/agents/billing/conversations";
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };

// Start a conversation as user_123, then read it until its status is no longer working.
const started = await fetch(conversations, { method: "POST", headers, body: JSON.stringify({ text: "Where is my refund?" }) });
const { conversation } = await started.json();
const reply = await (await fetch(`${conversations}/${conversation}`, { headers })).json();
  • POST /v1/chat-tokens {"agent", "person", "ttl"?} needs a key with agent.chat_as and agent.chat that reaches the agent. ttl is milliseconds or a duration such as "15 minutes", from 1 minute to 1 hour, and an hour by default. The helper has no chat tokens.
  • Send the token as Authorization: Bearer slc_…. It starts, lists, reads, streams and adds to its person's conversations with its agent, and nothing else. Another agent returns 403 with "reason": "this chat token is for another agent", any other route 403, and another person's conversation 404. It never lists everyone's conversations, whatever its key can do.
  • A token reads what its person may see: {id, agent, person, personLabel, status, startedAt, lastAt, busy, items, approvals, lastEventId}. items holds only user and assistant messages and error, what the person is told when the agent couldn't answer, without the model provider's own message. approvals holds only the requests still waiting. Its stream is the same: user, text, approval and status events, and a failed status's error without the provider's message. Tool calls and results, models, cost and budget stay with your key: past the agent's monthly budget a token gets 429 {"error": "monthly budget reached"} and Retry-After, without the amount. Lists leave out costUsd, version and run.
  • After expiresAt a token returns 401 {"error": "chat token expired"}: mint a new one. A token also stops working once its key is revoked or expires.
  • A page on any site can call these routes with a token: their answers carry Access-Control-Allow-Origin: *, a 429 included. Answers to API keys don't, so a browser can't use a key.
  • One person's tokens make at most 120 requests a minute, and all of a workspace's tokens together 1,200. That's apart from the 1,200 /v1 calls of your keys and the dashboard, so browsers can't slow down your server's calls. Messages also count toward the person's and the workspace's limits.

Stream a conversation

GET …/conversations/:conversation/stream sends the conversation as it happens, as Server-Sent Events: the agent's text as it is written, its tool calls and their results, approvals and status changes. Anyone who can read the conversation can stream it. Each event is an id and one data: line of JSON with a type.

shellcurl
# Send the next message. Answers 202.
curl -X POST https://api.shiftlabs.org/v1/agents/billing/conversations/<conversation>/messages \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d '{"text": "When was it paid?"}'

# Stream the answer. -N prints each event as it arrives; the stream ends when the agent is done.
curl -N https://api.shiftlabs.org/v1/agents/billing/conversations/<conversation>/stream \
  -H "authorization: Bearer $SHIFTLABS_API_KEY"
  • 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

    As in the transcript: {"id", "name", "arguments", "at"}, then {"id", "name", "isError", "text", "at"}.

  • approval

    {"id", "tool", "status", "requestedAt", "expiresAt", "decidedAt"}: a tool call waits for an approver, or was decided.

  • status

    {"status", "error"?}: the conversation's status. On failed, error says why.

  • A stream starts with your latest message and the answer so far. Then come the approvals whose answer hasn't reached the conversation yet, and its status.
  • It ends after a status other than working: close your client then, or it reconnects for nothing. Reconnecting with that last id while the conversation is at rest gets 204. Open it again after your next message, or once the status is working again, for example after an approval.
  • Reconnect with the last id you got in a Last-Event-ID header, or as ?lastEventId=, and the stream goes on from there without repeating text. A conversation's lastEventId streams what came after its items.
  • A browser's EventSource can't send the authorization header. Read the stream with fetch, or an SSE client that sends headers.
  • A stream also ends after 5 minutes: reconnect to go on. While nothing else happens, a comment line comes every 20 seconds.
  • A conversation takes 10 streams at once. The next one gets 429.
  • Streaming someone else's conversation needs conversation.read_others, and is written to the audit log like a read.
  • A chat token streams only what its person may see: no tool_call, tool_result or approval_update events.

Scheduled messages

A schedule can message an agent on a cron. Each tick starts a new conversation with message, and the agent answers in the background as it does for a person.

shiftlabs.config.ts
import { project, schedule } from "shiftlabs";

export default project({
  schedules: {
    "monday-summary": schedule({ agent: "billing", message: "Summarize last week's refunds.", cron: "0 9 * * 1", timezone: "America/Detroit" }),
  },
});
  • The conversation belongs to the schedule: its person is schedule:<name> and its personLabel is schedule <name>. Developers and admins see it with ?all=1, as they see other people's conversations. No one can add messages to it.
  • No one sent the message, so the agent acts for no person: platform tools, which read the workspace as the person talking, refuse, and the built-in helper can't be scheduled. Workflow tools and code tools work as usual, and a tool that needs approval waits for an approver.
  • Each message counts like any agent message: toward runs per day, the model allowance and the agent's budgets. A message a limit refuses starts nothing, and the Schedules screen shows why.
  • The agent must exist once the release is in, and adding or changing an agent schedule needs agents turned on and the agent.deploy capability. While agents are off, each tick starts nothing and the Schedules screen shows agents are off in this workspace. The agent's current version answers, on models your plan includes.
  • message is 1 to 4,000 characters, and can't start with Approval update:.

Budgets

budget caps what the agent spends on model use, in US dollars.

  • conversationUsd

    What one conversation may spend, above 0 and at most 5. Default 0.5.

  • monthlyUsd

    What all the agent's conversations may spend in a UTC month, above 0 and at most 1,000. Optional. Without it, the workspace's model allowance is the only monthly cap.

  • A turn that spends conversationUsd stops as budget reached. The conversation's next message returns 409 {"error": "budget reached"}.
  • A turn that spends monthlyUsd stops as monthly budget reached. Until the month ends, every message to the agent returns 429 {"error": "monthly budget reached", "monthlyUsd"} with Retry-After.
  • The current version's monthlyUsd holds for every conversation, whichever version it started on.
  • GET /v1/agents shows each agent's model cost this UTC month as stats.costMonthUsd.

Access

access says who may message the agent. Without it, every member can, from viewer up.

  • role

    The lowest role that may message the agent: viewer, developer or admin. Without it, only members may.

  • members

    Up to 100 members, by the email they sign in with, who may message the agent whatever their role.

  • Each message is checked against the current version's access, in new conversations and old ones. A message from outside it returns 403 {"error": "forbidden", "reason"}. People keep reading the conversations they started.
  • API keys count by their role.
  • GET /v1/agents shows canChat on each agent: whether you may message it. The dashboard shows the chat only to them.

Versions

Each deploy of an agent adds a version, numbered from 1, and makes it current. New conversations start on the current version. The helper isn't deployed: its version is a number that changes whenever its definition does, and the dashboard shows it as built in.

  • GET /v1/agents/:agent/versions lists the newest 50, each with its definition, deployedBy, and deployedByName for an API key.
  • A release makes agent definitions current together with workflow versions. An agent whose definition hasn't changed keeps its version. Rolling back a release makes its agent versions current again.
  • shiftlabs deploy and a template's Deploy each make one release.

Turn agents on or off

Agents are on in a new workspace. An admin turns them off, or on again, in Settings → Agents or with the API. The helper is on wherever agents are.

shellcurl
# Admin key. Answers {"on", "chosen", "override"}.
curl -X PUT https://api.shiftlabs.org/v1/workspace/agents \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d '{"enabled": false}'
  • While agents are off, the agent routes return 404, /v1/approvals lists only workflow runs' requests, a release that names agents returns 400, a workflow's step.ask fails with an error that says so, and the dashboard hides Helper and Agents.
  • Turning agents off keeps agents, conversations and their approvals.
  • GET /v1/me shows features.agents and agentsSwitch {on, chosen, override}. When shiftlabs has set agents on or off for the workspace (override), PUT returns 409. Contact support to change it.
  • Each change is written to the audit log as feature_set.

Limits

Agents count toward the workspace's plan. Model use, the helper's included, counts toward the model allowance.

Agent limits by plan
LimitFreePro
Agents the helper isn't counted325
Model tokens per 24 hours input and output50,0005,000,000
Model cost per UTC month$1$10
Modelsthe small modelsevery model
Messages per 24 hours counted apart from runs1,000250,000
Messages a minute30 per workspace, 10 per person or workflow30 per workspace, 10 per person or workflow
  • Past the model allowance, a message returns 429 {"error": "quota exceeded", "limit", "max"} with "limit": "modelTokensPerDay" or "modelCentsPerMonth", and Retry-After. A turn that uses up the allowance stops as allowance reached.
  • Past the messages per 24 hours, a message returns 429 with "limit": "runsPerDay" and Retry-After. Past the messages a minute, it returns 429 {"error": "rate limited"} with Retry-After: 60.
  • Model use past the allowance isn't billed. Messages wait until the allowance frees up.
  • Workflow tool calls are runs, with your plan's run limits. Each code tool call gets 2,000 ms of CPU.
  • On Pro, past the spending limit or while a payment is failing, messages return 429. See Plans and billing. While shiftlabs pauses new runs, they return 503. See Rate limits.

API

Define agents, upload their code and talk to them with the /v1 API.

Routes
RouteMethodMinimum role
/v1/workspace/agents {"enabled"}; {on, chosen, override}PUTadmin
/v1/agents each agent's current version, stats and canChat, the helper first with builtIn: trueGETviewer
/v1/agents/:agent the definition; {name, version}PUTdeveloper
/v1/agents/:agent/code body: TypeScript whose default export is tools({...}); {codeHash, tools, sizeBytes, warnings}PUTdeveloper
/v1/agents/:agent/versions newest first, 50 at mostGETviewer
/v1/agents/:agent/conversations POST {"text", "person"?}: 202 {conversation, status}; GET your own, ?person= an end user's, ?all=1 everyone's, ?workflows=1 the ones workflows started, ?run= one run'sGET · POSTviewer
/v1/agents/:agent/conversations/:conversation status, transcript, cost and approvals; ?person= for an end user'sGETviewer
/v1/agents/:agent/conversations/:conversation/messages {"text", "person"?}: 202 {conversation, status}POSTviewer
/v1/agents/:agent/conversations/:conversation/stream Server-Sent Events until the conversation comes to rest; Last-Event-ID or ?lastEventId= resumes, and 204 once there is nothing more; ?person= for an end user'sGETviewer
/v1/chat-tokens {"agent", "person", "ttl"?}: a chat token for one agent and end user, 1 minute to 1 hour; 201 {token, agent, person, expiresAt}POSTdeveloper key with agent.chat_as