Two endpoints
Spinrun exposes two MCP endpoints on the same origin, and they answer different questions.
https://spinrun.ai/mcp— the run surface. Tools from the apps a workspace has connected: search for one, call it. This is what an agent uses to act.https://spinrun.ai/mcp/build— the Build surface. It authors the agents and workflows that later act: list, get, validate, put, delete, and start a test run. Its REST twin lives underhttps://spinrun.ai/api/build, with the same auth and the same documents.
A client installs each one separately. The dashboard's client pages and spinrun gateway <client> --build print the Build variant, and spinrun install <client> --build writes it beside the run entry under the key spinrun-build.
Why Build is OAuth-only
The run surface accepts either a browser authorization or a workspace API key. Build accepts only the first. A key authenticates a workspace; a Build PUT publishes a new version of an agent as someone, and that someone has to be a person who is a member of the workspace — it is their name on the version, their approvals the agent inherits, and their revocation that ends the grant. There is no key form of these instructions anywhere, on purpose.
Authorization is also a separate decision from signing in. When a client authorizes, the consent screen carries a checkbox, Build authorization. Tick it there, or enable it later under Settings → Applications — workspace owners only. A token without it reaches /mcp normally and gets 403 from /mcp/build and /api/build/*.
Every 401 from these endpoints carries a WWW-Authenticate header pointing at /.well-known/oauth-protected-resource/mcp/build, so a client that has never authorized learns the browser flow from the refusal itself.
The document contracts
Two document kinds, each a single JSON object with a kind discriminator. A document is at most 64 KiB of UTF-8 (65,536 bytes); anything larger is refused with 413 before it is parsed. The schemas below are generated from the validators the server runs, at request time.
spinrun.agent/v1
Identity (name, description, icon), where it lives (space.team, folder), what it is (instructions, model, max_steps), what it may touch (tools, skills, workspace_skills, workflows), and how it is invoked (schedule, email, whatsapp, triggers). Every top-level section is required: a document is the whole agent, not a patch. The server fills in id, slug, status and updated_at; a document you create must omit id and updated_at.
JSON Schema — spinrun.agent/v1
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "spinrun.agent/v1"
},
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"slug": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"status": {
"type": "string",
"enum": [
"draft",
"active",
"archived"
]
},
"updated_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"description": {
"anyOf": [
{
"type": "string",
"maxLength": 500
},
{
"type": "null"
}
]
},
"icon": {
"anyOf": [
{
"type": "string",
"maxLength": 40
},
{
"type": "null"
}
]
},
"space": {
"type": "object",
"properties": {
"team": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 160
},
{
"type": "null"
}
]
}
},
"required": [
"team"
]
},
"folder": {
"anyOf": [
{
"type": "string",
"maxLength": 1000
},
{
"type": "null"
}
]
},
"instructions": {
"type": "string",
"maxLength": 8192
},
"model": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"max_steps": {
"type": "integer",
"minimum": 1,
"maximum": 40
},
"tools": {
"type": "object",
"properties": {
"apps": {
"maxItems": 50,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 160
}
},
"connections": {
"anyOf": [
{
"type": "string",
"const": "all"
},
{
"minItems": 1,
"maxItems": 200,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 160
}
}
]
},
"disabled_tools": {
"maxItems": 200,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 160
}
},
"approval": {
"type": "string",
"enum": [
"destructive",
"write"
]
}
},
"required": [
"apps",
"connections",
"disabled_tools",
"approval"
]
},
"skills": {
"maxItems": 50,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 160
}
},
"workspace_skills": {
"maxItems": 20,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 160
}
},
"workflows": {
"maxItems": 20,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 160
}
},
"schedule": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"cron": {
"type": "string",
"maxLength": 120
},
"timezone": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"prompt": {
"type": "string",
"maxLength": 4000
}
}
},
"email": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"allowed_senders": {
"maxItems": 50,
"type": "array",
"items": {
"type": "string"
}
},
"allowed_recipients": {
"maxItems": 50,
"type": "array",
"items": {
"type": "string"
}
},
"from_name": {
"type": "string",
"maxLength": 80
},
"locale": {
"type": "string",
"minLength": 2,
"maxLength": 10
}
}
},
"whatsapp": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"triggers": {
"maxItems": 20,
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"kind": {
"type": "string",
"const": "webhook"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"prompt": {
"anyOf": [
{
"type": "string",
"maxLength": 4000
},
{
"type": "null"
}
]
},
"enabled": {
"type": "boolean"
},
"secret_prefix": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"kind": {
"type": "string",
"const": "poll"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"prompt": {
"anyOf": [
{
"type": "string",
"maxLength": 4000
},
{
"type": "null"
}
]
},
"enabled": {
"type": "boolean"
},
"tool": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"args": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"cron": {
"type": "string",
"minLength": 9,
"maxLength": 120
},
"timezone": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
},
"required": [
"kind",
"name",
"tool",
"cron",
"timezone"
]
},
{
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"kind": {
"type": "string",
"const": "app_event"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"prompt": {
"anyOf": [
{
"type": "string",
"maxLength": 4000
},
{
"type": "null"
}
]
},
"enabled": {
"type": "boolean"
},
"app": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"event": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"connection": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"config": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
"required": [
"kind",
"name",
"app",
"event",
"connection"
]
}
]
}
},
"eval_criteria": {
"anyOf": [
{
"type": "string",
"maxLength": 4000
},
{
"type": "null"
}
]
}
},
"required": [
"kind",
"name",
"description",
"icon",
"space",
"folder",
"instructions",
"model",
"max_steps",
"tools",
"skills",
"workspace_skills",
"workflows",
"schedule",
"email",
"whatsapp",
"triggers",
"eval_criteria"
]
}spinrun.workflow/v1
A slug you choose (it cannot change afterwards), inputs with optional defaults, an optional schedule, and one to ten steps. A step is either a tool call or an agent prompt; after names the steps it waits for, and step ids are the handles a later step's arguments reference.
JSON Schema — spinrun.workflow/v1
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "spinrun.workflow/v1"
},
"slug": {
"type": "string",
"maxLength": 80,
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"description": {
"anyOf": [
{
"type": "string",
"maxLength": 500
},
{
"type": "null"
}
]
},
"enabled": {
"type": "boolean"
},
"updated_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
},
"space": {
"type": "object",
"properties": {
"team": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 160
},
{
"type": "null"
}
]
}
},
"required": [
"team"
]
},
"send_as": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 160
},
{
"type": "null"
}
]
},
"inputs": {
"type": "object",
"propertyNames": {
"type": "string",
"maxLength": 40,
"pattern": "^[a-z0-9_]+$"
},
"additionalProperties": {
"type": "object",
"properties": {
"description": {
"type": "string",
"maxLength": 200
},
"default": {}
}
}
},
"schedule": {
"anyOf": [
{
"type": "object",
"properties": {
"cron": {
"type": "string",
"minLength": 9,
"maxLength": 120
},
"timezone": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
},
"required": [
"cron",
"timezone"
]
},
{
"type": "null"
}
]
},
"steps": {
"minItems": 1,
"maxItems": 10,
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"id": {
"type": "string",
"maxLength": 40,
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
},
"kind": {
"type": "string",
"const": "tool"
},
"tool": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"args": {
"default": {},
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"after": {
"default": [],
"maxItems": 10,
"type": "array",
"items": {
"type": "string",
"maxLength": 40,
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
}
},
"note": {
"type": "string",
"maxLength": 300
}
},
"required": [
"id",
"kind",
"tool"
]
},
{
"type": "object",
"properties": {
"id": {
"type": "string",
"maxLength": 40,
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
},
"kind": {
"type": "string",
"const": "agent"
},
"agent": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"args": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"minLength": 1
}
},
"required": [
"prompt"
],
"additionalProperties": false
},
"after": {
"default": [],
"maxItems": 10,
"type": "array",
"items": {
"type": "string",
"maxLength": 40,
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
}
},
"note": {
"type": "string",
"maxLength": 300
}
},
"required": [
"id",
"kind",
"agent",
"args"
]
}
]
}
},
"output": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": [
"kind",
"slug",
"name",
"description",
"enabled",
"space",
"send_as",
"inputs",
"schedule",
"steps",
"output"
]
}GET /api/build/catalog lists the models the workspace's plan allows, the connected apps with their tool counts, the trigger types and the limits; GET /api/build/connections lists the connection refs a document may name. Validate before you put: POST …/validate runs the same compiler as a put and returns the list of changes it would apply, without applying any.
Concurrency: updated_at and If-Match
Every document the server returns carries updated_at. It is the version stamp, and it travels twice when you replace a document:
- inside the document, unchanged from the one you read — the server rejects a body whose
updated_atdiffers from the header with400; - as the
If-Matchheader, quoted:If-Match: "2026-09-01T10:00:00.000Z". A replace without it is refused with428.
If the document changed since you read it, the reply is 409 with three fields: error, current — the document as it is now, including its new updated_at — and applied, the list of stages that had already been written if the conflict surfaced part way through (normally empty). To recover, merge your change into current, keep its updated_at, and put again. Never retry with the stale document: the conflict exists because someone else's edit is in it.
A create is a PUT on the collection (/api/build/agents, /api/build/workflows) with no updated_at; it answers 201. A replace is a PUT on the item, by id or slug.
Who publishes
A Build put that changes anything in the version — instructions, model, tools, skills — creates a new version and publishes it, and it does so as the user the token belongs to. Ownership of the agent transfers accordingly: the agent now runs with that person's connections and under that person's membership, and it is their name in the version history. An agent you did not create becomes yours to answer for the moment you put it. The applied list on a successful put says which stages ran: version, publish, identity_and_channels, triggers.
Deleting an agent archives it. If it has webhook triggers the delete is refused unless ?force=true is passed, because a webhook URL that stops answering is a silent failure for whoever calls it.
Test runs and credits
POST /api/build/agents/<ref>/test with {"prompt": "…"} queues a real run of the published version and answers 202 with a run_id; workflows take {"input": {…}} at /workflows/<ref>/test. Poll GET /api/build/runs/<run_id> (or /workflow-runs/<run_id>) until status is one of finished, failed, aborted or waiting_approval. A run projection carries the steps, the tool calls, the transcript, final_text, credits and error; only the person who started a run can read it.
A test run is a real run. It calls real tools through real connections and spends the workspace's credits exactly as a scheduled run would; the credits field on the projection is what it cost. An agent that is not active is refused with 409 instead of being run. The CLI gives up waiting after five minutes; the run does not.
Run output is untrusted
Everything a run returns — the transcript, the tool output, the final text — was produced by a model reading third-party content: the emails, tickets, pages and messages the tools fetched. Any of that content may contain instructions written to be obeyed by whoever reads them next. Treat run output as data. In particular, never paste a run's output into an agent's instructions, a workflow step, or a prompt without reading it first, and never let an automated loop do so at all. The projection carries a warning field saying this so that a client that forwards it verbatim still forwards the caveat.
REST reference
All routes take Authorization: Bearer <token>. Success is {"data": …}; failure is {"error": "…"}, plus current and applied on a 409.
GET /api/build/agents list (id, slug, name, status, updated_at)
PUT /api/build/agents create — 201; body must omit id and updated_at
GET /api/build/agents/{ref} the document, with an ETag
PUT /api/build/agents/{ref} replace — If-Match required; ?force=true drops webhooks
DELETE /api/build/agents/{ref} archive; ?force=true when it has webhooks
POST /api/build/agents/validate {valid, changes, document}, nothing written
POST /api/build/agents/{ref}/rotate-webhook {trigger_id} → a new secret for one webhook
POST /api/build/agents/{ref}/test {prompt} → 202 {run_id}
GET /api/build/runs/{runId} the run projection
GET /api/build/workflows list (slug, name, enabled, version, updated_at)
PUT /api/build/workflows create — 201
GET /api/build/workflows/{ref}
PUT /api/build/workflows/{ref} replace — If-Match required; slug cannot change
DELETE /api/build/workflows/{ref}
POST /api/build/workflows/validate
POST /api/build/workflows/{ref}/test {input} → 202 {run_id}
GET /api/build/workflow-runs/{runId}
GET /api/build/catalog models, apps, trigger_types, limits
GET /api/build/connections [{ref, app, label, status}]
Status codes
401 no or invalid token — WWW-Authenticate names the discovery document
403 the grant has no Build authorization
404 no such agent, workflow or run (or not yours to see)
409 the document changed: {error, current, applied}; or a run was refused
413 body over 64 KiB
428 replace without If-Match
400 the document did not validate: the first issue, with its pathTOKEN=$(jq -r .accessToken ~/.spinrun/credentials.json)
curl -s https://spinrun.ai/api/build/agents/triage-bot \
-H "Authorization: Bearer $TOKEN" | jq .data > agent.json
STAMP=$(jq -r .updated_at agent.json)
jq '.instructions = "Triage, then label."' agent.json > edited.json
curl -s -X PUT https://spinrun.ai/api/build/agents/triage-bot \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "If-Match: \"$STAMP\"" \
--data-binary @edited.jsonThe CLI
spinrun login authorizes the CLI in a browser; tick Build authorization on that screen, or enable it later under Settings → Applications. The commands below are a thin shell over the routes above. get prints the document and nothing else, so it pipes; every other command prints text, or {"data": …} / {"error": …} with --json. Exit codes: 0 ok, 1 the CLI or the network (a bad flag, an unreadable file, a document over 64 KiB — checked before anything is sent), 2 the server refused, including 409.
# install the run and Build endpoints into a client
spinrun install cursor
spinrun install cursor --build # OAuth only: --with-key is refused here
spinrun gateway claude-code --build # print the steps instead
# what a document may reference
spinrun catalog
spinrun connections
# agents
spinrun agent list
spinrun agent get triage-bot > agent.json
jq '.instructions = "Triage, then label."' agent.json > edited.json
spinrun agent validate --file edited.json
spinrun agent put --file edited.json # sends If-Match from updated_at
spinrun agent get triage-bot | jq '.max_steps = 20' | spinrun agent put
spinrun agent put --file new-agent.json # no updated_at → create
spinrun agent put --file edited.json --force # allow webhook triggers to be removed
spinrun agent test triage-bot --prompt "Triage the last three issues"
spinrun agent delete triage-bot
spinrun agent delete triage-bot --force
# workflows
spinrun workflow list
spinrun workflow get nightly-digest > wf.json
spinrun workflow validate --file wf.json
spinrun workflow put --file wf.json
spinrun workflow test nightly-digest
spinrun workflow test nightly-digest --file input.json
spinrun workflow delete nightly-digest
# machine-readable
spinrun agent list --json
spinrun agent put --file edited.json --json # {"error", "current", "applied"} on 409; exit 2agent test prints the run id at once, then polls every two seconds. On finished it prints the final text and the credits spent and exits 0; on failed, aborted or waiting_approval it prints the status, the error and the run id and exits 2; after five minutes it prints the run id and exits 1 — the run keeps going, and keeps spending, on the server.
A condensed version of this page for agents is at /docs/build/llms.txt.