synsema

API reference

The platform, from a terminal or an agent.

Everything the dashboard does, the CLI, a runner or your own agent can do over HTTPS. Get a token from Settings or with the login call, then send it as Authorization: Bearer <token>. The machine-readable version is openapi.json; agents also read llms.txt.

Build with AI

Build Synsema apps fast with the coding agent you already use. The skill teaches it the language; the docs MCP gives it every page, the examples and a sandbox that runs and tests. Then paste the entry file into a new project, or point one at your repository.

1The skill

Claude Code, Cursor, Windsurf, Codex…

curl -sL https://raw.githubusercontent.com/kitecosmic/synsema/main/install-skill.sh | bash

The whole reference, read on demand. On Windows: Git Bash or WSL.

2The docs MCP

Search, every page, examples, a sandbox that runs and tests.

claude mcp add --transport http synsema-docs https://synsema.dev/mcp

Any MCP client: https://synsema.dev/mcp, streamable HTTP.

3The binary

To run and test on your machine.

curl -fsSL https://synsema.org/install.sh | sh
irm https://synsema.org/install.ps1 | iex

macOS and Linux, then Windows. Or npm i -g synsema.

Then ask your agent for the app you want. synsema test and synsema serve run it locally; this page deploys it.

What a project is

A project is a folder of Synsema files with an entry file. Its require block is the manifest: the platform grants what the plan allows and refuses to deploy anything above it, before it runs. A deploy sends the current package to a runner, which starts it in a container under that ceiling with --audit json: every capability check lands in the project's audit. There are three kinds, all with the same container and the same walls:

syn.toml

Optional, at the root of the package. It says what the code cannot say about itself; the require block stays the manifest.

name = "Invoice agent"
slug = "invoices"        # the URL name; free to change later
entry = "app.syn"
kind = "web"             # web | worker | job
memory = "512m"          # optional; the plan has a default
                         # and a ceiling per service

[schedule]               # jobs only, UTC: five cron fields
cron = "0 * * * *"       # or @hourly, @daily, @weekly, …

[secrets]                # what the program needs set, and
LLM_API_KEY = "the key"  # a line for the person setting it

[env]                    # variables with a default: shown
LLM_PROVIDER = "anthropic"   # in clear, editable

[hosts]                  # web only: extra names,
api = "the JSON API"     # label = what it is for

[volumes]                # folders the program writes into,
data = "./data"          # kept between deploys

[provision]              # a URL the platform asks once, at
env_url = "https://devnet.synsema.app/token"   # creation;
                         # its env object seeds the environment

Human gates: a person above the threshold

A program that writes let ok be approve "Pay 800 USDC to the vendor?" within 2h (or confirm, or ask "Which env?" with ["staging", "prod"] within 1h) stops at that line under serve. The runtime fires a signed webhook the platform receives, and the question appears in Approvals in the dashboard, on the project's page, and in GET /api/v1/approvals. Decide there, or with POST /api/v1/approvals/{id}; the runner hands your answer to the program with a one-time token nobody else has, and the request that was waiting continues. Past within with no answer, the program takes it as a no and the gate shows as expired. Nothing needs declaring: no capability, no configuration.

What waits, and what does not: only the request that reached the gate waits. Every other route of the service keeps answering, and a cron inside the service keeps firing (a cron that reaches a gate waits on its own thread and skips its next tick until then). A gate only waits under serve: in a job, or in a worker without a serve on block, there is no one to ask and approve answers no at once, on purpose — a program running unattended cannot grant itself the threshold. Keep within to minutes or hours: each gate that waits holds one connection of the service. For something that may take days, store what is pending, answer at once, and finish the work from a later route or run.

Jobs and their schedule

A job's deploy leaves its files on a runner and shows ready. A run starts from Run now on the project page, from POST /api/v1/projects/{id}/run, or from the schedule ([schedule] cron in syn.toml, or PUT /api/v1/projects/{id}/schedule): five cron fields — *, */n, a-b, lists, jan-dec, sun-sat — evaluated in UTC every minute, or an alias. Each run keeps its exit code and the tail of what it printed (GET /api/v1/projects/{id}/runs); every line also goes to the project's logs as it is written, and its capability checks to the audit. A job never overlaps itself: a run that finds the previous one still going is skipped. A run is given up after two hours.

Sleep

A web service nobody asks for a while sleeps: its container stops, and the next request starts it again in about a second (the request waits, it is not lost). On the Free plan every service sleeps after 10 idle minutes. On Pro and Enterprise a project chooses: kept warm by default, or PUT /api/v1/projects/{id}/sleep with after_minutes (0 keeps it warm); the choice applies on the next deploy. A worker never sleeps: nothing would arrive to wake it. A sleeping service still counts as live on the plan.

States

new (never deployed) · queued (waiting for a runner) · building, starting (the runner has it) · running · sleeping (idle; the next request wakes it) · ready (a job, deployed; runs on its schedule or when asked) · stopped · failed (the logs say why) · stopping, deleting (the runner is taking it down). A run of a job is queued, running, done or failed with its exit code; a gate is pending, approved, denied, answered or expired.

Routes

POST /api/v1/auth/login

Exchange email and password for an API token

Body:

Returns: {token, user}

curl -X POST https://synsema.com/api/v1/auth/login -H 'Content-Type: application/json' -d '{"email": "you@example.com", "password": "…"}'

POST /api/v1/auth/device

Ask to be signed in from a browser: a terminal never handles a password

Body:

Returns: {device_code, user_code, verify_url, expires_in, interval}

curl -X POST https://synsema.com/api/v1/auth/device -H 'Content-Type: application/json' -d '{"name": "syn cli"}'

POST /api/v1/auth/device/token

Poll until a person approves the code; the token is handed over once

Body:

Returns: {status: pending} or {status: ok, token, user}

curl -X POST https://synsema.com/api/v1/auth/device/token -H 'Content-Type: application/json' -d '{"device_code": "…"}'

GET /api/v1/me bearer

The account behind the token

Returns: {id, email, name, plan, admin, via}

curl https://synsema.com/api/v1/me -H "Authorization: Bearer $SYN_TOKEN"

GET /api/v1/recipes

The recipe catalog, mirrored from github.com/synsema/recipes

Returns: {items: [{slug, name, version, kind, entry, repo, ref, secrets, tagline, tagline_es, tagline_pt}]}

curl https://synsema.com/api/v1/recipes

GET /api/v1/projects bearer

Your projects

Returns: {items: [project]}

curl https://synsema.com/api/v1/projects -H "Authorization: Bearer $SYN_TOKEN"

POST /api/v1/projects bearer

Create a project: from files (the package `syn deploy` sends), a recipe, a public GitHub repository, or one pasted entry file

Body:

Returns: {project, ceiling, package, missing_secrets}

curl -X POST https://synsema.com/api/v1/projects -H "Authorization: Bearer $SYN_TOKEN" -H 'Content-Type: application/json' -d '{"name": "lampson", "recipe": "lampson"}'

GET /api/v1/projects/{id} bearer

One project, its effective ceiling, package, missing secrets, deploys, and `blocked`: why it cannot deploy on this plan, or empty

Returns: {project, ceiling, package, missing_secrets, blocked, deploys}

curl https://synsema.com/api/v1/projects/1 -H "Authorization: Bearer $SYN_TOKEN"

GET /api/v1/projects/{id}/package bearer

The current files of the project

Returns: {entry, kind, package, files: [{path, content, encoding}]}

curl https://synsema.com/api/v1/projects/1/package -H "Authorization: Bearer $SYN_TOKEN"

POST /api/v1/projects/{id}/deploys bearer

Queue a deploy of the current package, or send a new one first. 409 with the reason when a `require` line or the memory is above the plan, or the plan's services or memory budget are full

Body:

Returns: {deploy, ceiling, missing_secrets, url, logs}

curl -X POST https://synsema.com/api/v1/projects/1/deploys -H "Authorization: Bearer $SYN_TOKEN"

POST /api/v1/projects/{id}/stop bearer

Ask the runner to take the service down; the files and secrets stay

Returns: {ok, status}

curl -X POST https://synsema.com/api/v1/projects/1/stop -H "Authorization: Bearer $SYN_TOKEN"

DELETE /api/v1/projects/{id} bearer

Delete the project; if it ever ran, the runner erases its container and volume first

Returns: {ok, status}

curl -X DELETE https://synsema.com/api/v1/projects/1 -H "Authorization: Bearer $SYN_TOKEN"

GET /api/v1/projects/{id}/deploys/{did} bearer

One deploy and the ceiling it was given

Returns: deploy

curl https://synsema.com/api/v1/projects/1/deploys/1 -H "Authorization: Bearer $SYN_TOKEN"

GET /api/v1/projects/{id}/logs bearer

Live logs as server-sent events (event: log)

Returns: text/event-stream

curl -N https://synsema.com/api/v1/projects/1/logs -H "Authorization: Bearer $SYN_TOKEN"

GET /api/v1/projects/{id}/logs/tail bearer

Log lines after an id, as JSON (?after=<id>); what `syn logs` polls

Returns: {items: [{id, at, line}], last}

curl "https://synsema.com/api/v1/projects/1/logs/tail?after=0" -H "Authorization: Bearer $SYN_TOKEN"

GET /api/v1/projects/{id}/audit bearer

Every capability check, declared versus done (?only=granted|denied, ?q=text in the capability, scope, origin or reason)

Returns: {items: [{ts, capability, scope, granted, origin, why}]}

curl "https://synsema.com/api/v1/projects/1/audit?only=denied&q=exec" -H "Authorization: Bearer $SYN_TOKEN"

GET /api/v1/projects/{id}/secrets bearer

The secrets set (names and fingerprints, never values) and the ones the manifest still needs

Returns: {items: [{name, fingerprint, created_at}], missing: [name]}

curl https://synsema.com/api/v1/projects/1/secrets -H "Authorization: Bearer $SYN_TOKEN"

PUT /api/v1/projects/{id}/secrets/{name} bearer

Set a secret (UPPER_SNAKE_CASE); the value is never returned

Body:

Returns: {ok, name}

curl -X PUT https://synsema.com/api/v1/projects/1/secrets/LLM_API_KEY -H "Authorization: Bearer $SYN_TOKEN" -H 'Content-Type: application/json' -d '{"value": "sk-…"}'

GET /api/v1/projects/{id}/env bearer

The whole environment the container gets: variables with their value, secrets with their fingerprint, what is still missing, and whether a change waits for a redeploy

Returns: {items: [{name, kind, value, fingerprint, created_at}], missing: [name], needs_redeploy}

curl https://synsema.com/api/v1/projects/1/env -H "Authorization: Bearer $SYN_TOKEN"

PUT /api/v1/projects/{id}/env/{name} bearer

Set a variable or a secret; without kind, a name the program or the recipe declares as a secret is one, the rest are variables. The service picks it up on its next deploy

Body:

Returns: {ok, name, kind}

curl -X PUT https://synsema.com/api/v1/projects/1/env/LLM_PROVIDER -H "Authorization: Bearer $SYN_TOKEN" -H 'Content-Type: application/json' -d '{"value": "anthropic"}'

DELETE /api/v1/projects/{id}/env/{name} bearer

Remove one entry of the environment

Returns: {ok, name}

curl -X DELETE https://synsema.com/api/v1/projects/1/env/LLM_PROVIDER -H "Authorization: Bearer $SYN_TOKEN"

GET /api/v1/approvals bearer

The human gates your running programs are waiting on (`approve`, `confirm`, `ask` reached under serve), and the ones decided lately

Returns: {items: [{id, project_id, project, gate_id, type, message, status, expires_at, received_at}], recent: [...]}

curl https://synsema.com/api/v1/approvals -H "Authorization: Bearer $SYN_TOKEN"

POST /api/v1/approvals/{id} bearer

Decide one gate: {decision: true|false} for approve and confirm, {value} for ask. The runner hands it to the program with its one-time token; 409 when it was already decided or expired

Body:

Returns: {ok, status: approved | denied | answered}

curl -X POST https://synsema.com/api/v1/approvals/7 -H "Authorization: Bearer $SYN_TOKEN" -H 'Content-Type: application/json' -d '{"decision": true}'

GET /api/v1/projects/{id}/approvals bearer

The gates of one project, pending and decided

Returns: {items: [approval]}

curl https://synsema.com/api/v1/projects/1/approvals -H "Authorization: Bearer $SYN_TOKEN"

POST /api/v1/projects/{id}/run bearer

Run a job now (kind = job): the runner starts it in a fresh container on its next poll. 409 while a run is in progress or before the first deploy

Returns: {run: {id, status, trigger, exit_code, output, created_at, started_at, finished_at}}

curl -X POST https://synsema.com/api/v1/projects/1/run -H "Authorization: Bearer $SYN_TOKEN"

GET /api/v1/projects/{id}/runs bearer

The runs of a job, newest first, and its schedule

Returns: {items: [run], schedule}

curl https://synsema.com/api/v1/projects/1/runs -H "Authorization: Bearer $SYN_TOKEN"

GET /api/v1/projects/{id}/runs/{rid} bearer

One run, with the tail of its output

Returns: run

curl https://synsema.com/api/v1/projects/1/runs/3 -H "Authorization: Bearer $SYN_TOKEN"

PUT /api/v1/projects/{id}/schedule bearer

The schedule of a job: five cron fields (minute hour day month weekday, UTC) or @hourly, @daily, @weekly, @monthly, @yearly; empty = only when asked

Body:

Returns: {ok, schedule, means}

curl -X PUT https://synsema.com/api/v1/projects/1/schedule -H "Authorization: Bearer $SYN_TOKEN" -H 'Content-Type: application/json' -d '{"cron": "0 * * * *"}'

PUT /api/v1/projects/{id}/sleep bearer

Idle minutes before a web service sleeps (its next request wakes it); 0 keeps it warm. Pro and up; the Free plan always sleeps after 10. Applies on the next deploy

Body:

Returns: {ok, sleep_after, note}

curl -X PUT https://synsema.com/api/v1/projects/1/sleep -H "Authorization: Bearer $SYN_TOKEN" -H 'Content-Type: application/json' -d '{"after_minutes": 0}'