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:
- web —
synsema serve <entry> with a URL: <slug>.synsema.app, extra names <slug>-<label>.synsema.app, a domain of your own on Pro. The program gets them as SYNSEMA_HOST, SYNSEMA_HOSTS, SYNSEMA_HOST_<LABEL>.
- worker — no URL, always on:
synsema run <entry>. An entry with a serve on block is served on a loopback port instead, so its human gates work (below).
- job — runs and ends:
synsema run <entry> in a fresh container per run, on a schedule or when asked. Nothing runs in between and no slot of the plan is taken.
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:
email — stringpassword — string
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:
name — string (what to call this device)
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:
name — stringfiles — [{path, content, encoding: utf8 | base64}] (optional)recipe — slug (optional)repo — https://github.com/owner/repo (optional)ref — branch or tag (optional, with repo)source — string, the entry file (optional)entry — string (optional)kind — web | worker (optional)
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:
files — [{path, content, encoding}] (optional, a new package)entry — string (optional, with files)source — string (optional, replaces the entry file)
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:
value — stringkind — secret | variable (optional)
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:
decision — true | false (approve, confirm)value — string (ask)
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}'