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.
# 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"]
}
}]
}'
title1 to 80 characters, shown in the dashboard.
descriptionWhat the agent does, up to 300 characters, shown in the dashboard's list of agents. Optional.
instructionsWhat the agent is for and how it works, 1 to 8,000 characters. In a project,
file("./x.md")keeps them in a file.modelA model id from Models. Default
@cf/zai-org/glm-4.7-flash.fallbackUp to 3 more model ids, tried in order when a request to
modelfails before it answers. None may be of a lower tier thanmodel. Left out, the platform picks one;[]means none.maxTurnsModel 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.welcomeShown in the dashboard's chat before the first message, up to 500 characters. Optional.
suggestionsUp to 4 first messages, each up to 200 characters, offered in the dashboard's chat. Choosing one sends it.
toolsUp to 16 workflow tools, counting the tools in the agent's code.
codeThe
codeHashof the agent's code. Every tool in the code joins the agent.
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.
| Model | Tier | Input | Output |
|---|---|---|---|
@cf/google/gemma-4-26b-a4b-it | 4 | $0.10 | $0.30 |
@cf/ibm-granite/granite-4.0-h-micro | 1 | $0.017 | $0.112 |
@cf/meta/llama-3.1-8b-instruct-fp8-fast | 2 | $0.045 | $0.384 |
@cf/meta/llama-3.2-1b-instruct | 1 | $0.027 | $0.201 |
@cf/meta/llama-3.2-3b-instruct | 1 | $0.051 | $0.335 |
@cf/mistral/mistral-7b-instruct-v0.1 | 1 | $0.11 | $0.19 |
@cf/qwen/qwen3-30b-a3b-fp8 | 4 | $0.051 | $0.335 |
@cf/zai-org/glm-4.7-flash | 4 | $0.06 | $0.40 |
| Model | Tier | Input | Output |
|---|---|---|---|
@cf/aisingapore/gemma-sea-lion-v4-27b-it | 3 | $0.351 | $0.555 |
@cf/deepseek-ai/deepseek-r1-distill-qwen-32b | 3 | $0.497 | $4.881 |
@cf/deepseek-ai/deepseek-v4-flash-0731 cached input $0.014 | 5 | $0.44 | $1.32 |
@cf/deepseek-ai/deepseek-v4-pro-0813 cached input $0.044 | 5 | $1.32 | $3.96 |
@cf/deepseek-ai/deepseek-v4.1-flash-1017 cached input $0.006 | 5 | $0.30 | $1.20 |
@cf/google/gemma-3-12b-it | 2 | $0.345 | $0.556 |
@cf/meta/llama-2-7b-chat-fp16 | 1 | $0.556 | $6.667 |
@cf/meta/llama-3-8b-instruct | 2 | $0.282 | $0.827 |
@cf/meta/llama-3-8b-instruct-awq | 2 | $0.123 | $0.266 |
@cf/meta/llama-3.1-70b-instruct-fp8-fast | 3 | $0.293 | $2.253 |
@cf/meta/llama-3.1-8b-instruct | 2 | $0.282 | $0.827 |
@cf/meta/llama-3.1-8b-instruct-awq | 2 | $0.123 | $0.266 |
@cf/meta/llama-3.1-8b-instruct-fp8 | 2 | $0.152 | $0.287 |
@cf/meta/llama-3.2-11b-vision-instruct | 2 | $0.049 | $0.676 |
@cf/meta/llama-3.3-70b-instruct-fp8-fast | 3 | $0.293 | $2.253 |
@cf/meta/llama-4-scout-17b-16e-instruct | 3 | $0.27 | $0.85 |
@cf/mistralai/mistral-small-3.1-24b-instruct | 3 | $0.351 | $0.555 |
@cf/moonshotai/kimi-k2.5 cached input $0.10 | 5 | $0.60 | $3.00 |
@cf/moonshotai/kimi-k2.6 cached input $0.16 | 5 | $0.95 | $4.00 |
@cf/moonshotai/kimi-k2.7-code cached input $0.19 | 4 | $0.95 | $4.00 |
@cf/nvidia/nemotron-3-120b-a12b | 4 | $0.50 | $1.50 |
@cf/openai/gpt-oss-120b | 4 | $0.35 | $0.75 |
@cf/openai/gpt-oss-20b | 4 | $0.20 | $0.30 |
@cf/qwen/qwen2.5-coder-32b-instruct | 3 | $0.66 | $1.00 |
@cf/qwen/qwen3.8-27b | 5 | $0.45 | $3.20 |
@cf/qwen/qwq-32b | 3 | $0.66 | $1.00 |
@cf/zai-org/glm-5.2 cached input $0.26 | 5 | $1.40 | $4.40 |
@cf/zai-org/glm-5.3 cached input $0.26 | 5 | $1.40 | $4.40 |
@cf/zai-org/glm-5.3-flash cached input $0.03 | 5 | $0.15 | $0.50 |
- Any other model id returns
400. - A fallback must be the same tier as
modelor higher, so a failing model never lowers the quality of answers. A fallback of a lower tier returns400. - Without
fallback, the platform picks one: of the models at the lowest tier at or above that ofmodel, the one closest in price. For a small model it picks a small model. - When a fallback answers, the conversation's
modelitem 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.
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() }),
}),
],
}),
},
});
workflowThe workflow's name. Deploy the workflow before the agent.
descriptionWhen to use the tool, 1 to 1,024 characters. The model chooses tools by it.
parametersA 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 returns400and asks forparameters. Inshiftlabs.config.ts, any Standard JSON Schema too, such as a zod 4.2+ schema:shiftlabs deploysends 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, eachrole:admin,role:manageror 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'sapproversbeside it, and a deploy takes that shape too.waitSecondsHow 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
agentandorigin_ref<agent>/<conversation id>. - The model reads
Run <id> completed. Output: <JSON>, or the run's error. A run still going afterwaitSecondsgoes 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 deployreleases 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.
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();
}),
});
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",
}),
},
});
# 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>"
}'
descriptionWhen to use the tool, 1 to 1,024 characters. The model chooses tools by it. Required.
parametersA JSON Schema with
"type": "object", up to 8 KB. Default: any object. The upload converts a Standard JSON Schema to JSON Schema.waitSecondsHow 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 atool(...)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"}, anddetailnames the tool. Descriptions and parameters together are at most 256 KiB. PUT /v1/agents/:agentand a release that namecodecount 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 withtoArray(),one()androwsWritten. 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
waitSecondspass 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.
# 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?"}'
workingThe agent is calling models and tools.
answeredThe agent replied. Send the next message.
waiting for approvalA tool call waits for an approver. See Approvals.
stopped at maxTurnsThe model kept asking for tools past
maxTurns. Send another message to go on.budget reachedThe conversation spent its
budget.conversationUsd. The next message returns409 {"error": "budget reached"}. Start a new conversation.monthly budget reachedThe agent spent its
budget.monthlyUsdfor this UTC month. See Budgets.allowance reachedThe workspace's model allowance ran out during the turn.
failedA model request failed, or sent nothing for 120 seconds. The transcript's
erroritem 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.askstarts a conversation the workflow owns. Itspersonisworkflow:<name>, itspersonLabelisworkflow <name>, and itsrunis 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 anerroritem 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 …/conversationslists your own, newest first, 50 at most, and with?person=an end user's. Developers and admins can list everyone's with?all=1and read any conversation. Lists leave out the conversations workflows started:?workflows=1lists those instead, and?run=<id>one run's. Reading other people's conversations is written to the audit log. A viewer gets403for?all=1,?workflows=1or?run=and404for another person's conversation.- A conversation reads
{id, agent, person, personLabel, run, version, status, startedAt, lastAt, costUsd, busy, turns, maxTurns, budgetUsd, items, approvals, lastEventId}.runisnullunless a workflow owns it.budgetUsdis its version'sbudget.conversationUsd.itemsis 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_updateanderror.lastEventIdis where its stream stands afteritems. - 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.
# 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_asis in no role. An admin gives it to a developer or admin key by listing it incapabilities, or on the API keys screen under Limit this key → Not in any role. Members signed in to the dashboard never hold it.personis 1 to 128 letters, digits and_ - . @ : | + =, such as your app's user id. Send it in the body of aPOSTand as?person=on aGET. A key withoutagent.chat_asgets403with"capability": "agent.chat_as", and a bad name gets400.- The conversation's
personisuser:<person>, and itspersonLabelis 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), soceo@acme.comfrom your app isn't taken for the memberceo@acme.com. - Without
person, the key is the person, as before. Asuser_123, a key can't read or add to its own conversations, a member's, a workflow's or another person's: they return404. - 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
personon it returns400. ?person=can't be combined with?all=1,?workflows=1or?run=: that's403withoutconversation.read_othersand400with 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 }).
# 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"}'
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 withagent.chat_asandagent.chatthat reaches the agent.ttlis 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 returns403with"reason": "this chat token is for another agent", any other route403, and another person's conversation404. 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}.itemsholds onlyuserandassistantmessages anderror, what the person is told when the agent couldn't answer, without the model provider's own message.approvalsholds only the requests still waiting. Its stream is the same:user,text,approvalandstatusevents, and a failed status'serrorwithout the provider's message. Tool calls and results, models, cost and budget stay with your key: past the agent's monthly budget a token gets429 {"error": "monthly budget reached"}andRetry-After, without the amount. Lists leave outcostUsd,versionandrun. - After
expiresAta token returns401 {"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: *, a429included. 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
/v1calls 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.
# 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_resultAs 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. Onfailed,errorsays 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
statusother thanworking: close your client then, or it reconnects for nothing. Reconnecting with that lastidwhile the conversation is at rest gets204. Open it again after your next message, or once the status isworkingagain, for example after an approval. - Reconnect with the last
idyou got in aLast-Event-IDheader, or as?lastEventId=, and the stream goes on from there without repeating text. A conversation'slastEventIdstreams what came after itsitems. - A browser's
EventSourcecan't send theauthorizationheader. Read the stream withfetch, 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_resultorapproval_updateevents.
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.
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
personisschedule:<name>and itspersonLabelisschedule <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.deploycapability. While agents are off, each tick starts nothing and the Schedules screen showsagents are off in this workspace. The agent's current version answers, on models your plan includes. messageis 1 to 4,000 characters, and can't start withApproval update:.
Budgets
budget caps what the agent spends on model use, in US dollars.
conversationUsdWhat one conversation may spend, above 0 and at most 5. Default 0.5.
monthlyUsdWhat 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
conversationUsdstops asbudget reached. The conversation's next message returns409 {"error": "budget reached"}. - A turn that spends
monthlyUsdstops asmonthly budget reached. Until the month ends, every message to the agent returns429 {"error": "monthly budget reached", "monthlyUsd"}withRetry-After. - The current version's
monthlyUsdholds for every conversation, whichever version it started on. GET /v1/agentsshows each agent's model cost this UTC month asstats.costMonthUsd.
Access
access says who may message the agent. Without it, every member can, from viewer up.
roleThe lowest role that may message the agent:
viewer,developeroradmin. Without it, onlymembersmay.membersUp 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 returns403 {"error": "forbidden", "reason"}. People keep reading the conversations they started. - API keys count by their role.
GET /v1/agentsshowscanChaton 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/versionslists the newest 50, each with its definition,deployedBy, anddeployedByNamefor 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 deployand 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.
# 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/approvalslists only workflow runs' requests, a release that names agents returns400, a workflow'sstep.askfails with an error that says so, and the dashboard hides Helper and Agents. - Turning agents off keeps agents, conversations and their approvals.
GET /v1/meshowsfeatures.agentsandagentsSwitch{on, chosen, override}. When shiftlabs has set agents on or off for the workspace (override),PUTreturns409. 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.
| Limit | Free | Pro |
|---|---|---|
| Agents the helper isn't counted | 3 | 25 |
| Model tokens per 24 hours input and output | 50,000 | 5,000,000 |
| Model cost per UTC month | $1 | $10 |
| Models | the small models | every model |
| Messages per 24 hours counted apart from runs | 1,000 | 250,000 |
| Messages a minute | 30 per workspace, 10 per person or workflow | 30 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", andRetry-After. A turn that uses up the allowance stops asallowance reached. - Past the messages per 24 hours, a message returns
429with"limit": "runsPerDay"andRetry-After. Past the messages a minute, it returns429 {"error": "rate limited"}withRetry-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 return503. See Rate limits.
API
Define agents, upload their code and talk to them with the /v1 API.
| Route | Method | Minimum role |
|---|---|---|
/v1/workspace/agents {"enabled"}; {on, chosen, override} | PUT | admin |
/v1/agents each agent's current version, stats and canChat, the helper first with builtIn: true | GET | viewer |
/v1/agents/:agent the definition; {name, version} | PUT | developer |
/v1/agents/:agent/code body: TypeScript whose default export is tools({...}); {codeHash, tools, sizeBytes, warnings} | PUT | developer |
/v1/agents/:agent/versions newest first, 50 at most | GET | viewer |
/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= | GET · POST | viewer |
/v1/agents/:agent/conversations/:conversation status, transcript, cost and approvals; ?person= for an end user's | GET | viewer |
/v1/agents/:agent/conversations/:conversation/messages {"text", "person"?}: 202 {conversation, status} | POST | viewer |
/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's | GET | viewer |
/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} | POST | developer key with agent.chat_as |