Tasks
Delegate a unit of work — Spunto gives it a worker, a branch and an agent session, and hands you back something to review.
A task is a unit of delegated work: "fix the login that breaks on Safari".
You describe it, Spunto gives it a worker, creates a branch, starts an agent session with your prompt, and tells you when there is something to review. The machine is an implementation detail you can click through to — the work is the object you manage.
From a project page, click New task and fill in three fields:
- Title — one line. It also names the branch (
task/fix-the-login-that-breaks-on-safari-a1b2c3). - Prompt — the instructions, in as much detail as you would give a teammate.
- Base branch — optional. Empty means the repository's default branch.
The task appears immediately as Queued and moves on its own from there.
Over the API:
curl -X POST \
-H "Authorization: Bearer spk_..." \
-H "Content-Type: application/json" \
-d '{"title":"Fix the login that breaks on Safari","prompt":"The form does not submit when the email contains a +."}' \
https://spunto.net/api/orgs/{orgId}/projects/{projectId}/tasksThe call returns straight away — allocating a machine and starting an agent takes minutes, and
runs as a durable background job. Poll GET .../tasks/{taskId}, or watch the panel.
API keys need the tasks:read / tasks:write scopes. tasks:write starts an agent session
inside a container, so it grants shell-class access — it is deliberately not implied by
projects:write.
| State | What it means |
|---|---|
queued | waiting for a machine |
running | the agent session is alive |
in-review | the session ended cleanly — there is something to judge |
done | you accepted it |
failed | the session died, allocation failed, or you dropped the task |
These are deliberately coarse, and they come from the only thing Spunto can honestly observe on
its own: the agent session it started. Spunto talks to no forge. An exit code says a process
stopped, never that the work is good — so derivation stops at in-review.
There is no "waiting for your input" state either: it cannot be detected honestly yet, and a state that lies is worse than one that is missing.
A task owns its worker until it is done or failed — including while you review it. That matters more than it sounds: handing the machine to the next task would overwrite the test data, the artefacts and the working tree you opened the review to judge.
You choose what the machine does during review, per project:
- Stop it (default) — the container is stopped, everything stays on disk, one click restarts it. You pay for disk, nothing else. Review takes as long as it takes.
- Keep it running — instant to pick back up, at the cost of a live container for the duration.
Once a task is done or failed, its worker goes back to the pool and the next task reuses it — restarting it if needed, which is far cheaper than building a fresh image.
All of that is visible from the task itself, under Machine: which worker it got and whether it
was recycled from the pool or spawned for this task, which node it sits on, its live CPU and
memory while the container runs, how long it took to boot — and a link into the editor when it is
up. While a task is queued, that is all there is to see, so the cockpit shows the boot itself:
the machine being found or created, the image, each repository being cloned, your postCreate and
postStart commands, the branch, and finally the session starting. A task that fails before its
session shows the same list, stopped on the step that broke.
A task in review shows two actions on its row:
- Accept marks the task
done, running your project's accept command first if it has one (exit code 0 required). It is always available — a project with no configuration at all still needs a way to close a task. - Drop kills the agent session, runs your cleanup command if you have one, and marks the task
failedwith the reason.
Spunto merges nothing on its own initiative. What accepting does is yours to define — see below.
Everything opinionated about a task is a project setting, under Edit → Advanced options → Lifecycle → Delegated work. Leave them empty and you get sensible defaults.
| Setting | Empty means | Set it to |
|---|---|---|
| Agent command | Claude Code | any CLI that reads its prompt on stdin |
| Session stream | nothing captured but the stdout tail | Claude Code, or your own JSONL — see below |
| Accept command | Accept just marks the task done | gh pr merge, glab mr merge, a deploy… |
| Drop command | just kill the session | whatever cleanup you need |
| Worker during review | Stop it | Keep it running |
An agent session lasts minutes to hours. With Session stream left on Don't capture, Spunto has exactly one fact to offer for that whole span — "it's running" — and the output arrives in one block at the end.
Set it to Claude Code and the task page fills in as the work happens: what the agent said, the
tools it ran with their output, the plan it is following, what it cost. Spunto adds
--output-format stream-json --verbose to your agent command (and only if your command doesn't
already set --output-format itself, so a pipeline stays yours).
What gets stored is Spunto's vocabulary, not the CLI's: session.started, message,
thinking, tool.call, tool.result, plan, usage, session.ended — plus raw for anything
an adapter couldn't place. There has to be a common denominator: you cannot render Codex, Gemini
CLI and a shell script of your own through one vendor's message format.
And the harness's own line is kept next to every event (?source=true on the events endpoint).
Normalizing without it would be a one-way door: an adapter that maps a field wrong, or hasn't
learned a new one yet, would destroy it on write. With it, type and payload are a projection —
re-derivable from the line that produced them.
Which is also why there is a third option, Spunto events (JSONL): print one of those JSON objects per line from your own harness and it renders the same way, with nothing else to learn.
curl -H "Authorization: Bearer spk_..." \
"https://spunto.net/api/orgs/{orgId}/projects/{projectId}/tasks/{taskId}/events?since=0"seq is dense and monotonic per task — send the highest one you have back as since and you
never re-read what you already have.
At the bottom of the task page there is a reply box. Write "in fact, do X instead" and the agent resumes — same conversation, same branch, same worker (restarted first if review mode parked it) — and its events continue the same timeline.
It is a new turn, not an interruption: a session that is still talking has no open stdin to slip anything into, so the box is only live while the task is in review, which is exactly when it is waiting for you anyway. When it is disabled it says which of the three reasons applies.
Resuming needs the harness's session id, and Spunto only knows it because it read it off the stream — so answering requires Session stream to be set. Otherwise you would get a fresh session with no memory of the turn it is meant to continue, which is worse than a clear refusal. A harness that resumes differently gets a Follow-up command of its own.
Every one of these runs inside the task's worker with these variables available:
SPUNTO_TASK_ID SPUNTO_TASK_BRANCH SPUNTO_TASK_STATE
SPUNTO_TASK_TITLE SPUNTO_TASK_BASE_BRANCH SPUNTO_TASK_SESSION_STATUSYour project's secrets are there too, resolved exactly as they are for a worker you open
yourself. So an accept command can be gh pr merge without you pasting a token into the project
settings.
The only contract is that your command reads the prompt on standard input:
my-agent --headless --yesSpunto once let a project point a status command at its forge: a script run in the worker,
every few seconds, whose output could move a task — including all the way to done.
It is gone, and the reason is worth stating, because it looks like a feature to lose. It meant your forge could reach into your machines: merge a pull request from your phone, and a container you were still reviewing got parked, a task you had never looked at closed itself, and the pool handed that machine to the next task — overwriting the working tree you had merged from. The trigger was elsewhere, the consequence was here, and nothing on screen connected the two.
So a task's state comes from what Spunto started and what you clicked, and from nothing else:
| The session | The task |
|---|---|
| is running | running |
| ended badly | failed |
| ended cleanly | in-review — and it waits |
Your forge is still where the work lands, and your accept command is still where a merge happens — the difference is that you trigger it, from the task you are looking at. If a pull request was merged elsewhere, pressing Accept is how you tell Spunto the work is over; the merge command is idempotent on an already-merged branch, or you leave it empty and Accept simply closes the task.
# Accept command
gh pr merge "$SPUNTO_TASK_BRANCH" --squash --delete-branch
# Drop command
gh pr close "$SPUNTO_TASK_BRANCH" --delete-branchThese run in the task's own worker — restarted first if review mode had parked it — so they can touch the checkout as well as the forge.
Nothing in a task's lifecycle calls GitHub. The only place a forge still appears is the clone that populates the worker, so the real prerequisite is a git repository exists in the workspace — not an account is connected. A project can build its own, which is the shortest way to try delegated work on a machine where you have no credentials set up:
# postCreate — a repository and its origin, inside the worker
cd /workspace
if [ ! -d .git ]; then
git init -q -b main .
echo ".sandbox-origin.git/" > .gitignore
git config user.email you@example.com && git config user.name "Sandbox"
git add -A && git commit -qm "initial"
git init -q --bare -b main .sandbox-origin.git
git remote add origin /workspace/.sandbox-origin.git
git push -q -u origin main
fi# Agent command — reads the prompt on stdin, makes a real commit on the task branch
sh -c 'prompt=$(cat); { echo "# $SPUNTO_TASK_TITLE"; echo; echo "$prompt"; } > "TASK-$SPUNTO_TASK_ID.md";
git add -A; git commit -qm "$SPUNTO_TASK_TITLE"; git push -q origin HEAD'The whole cycle then runs unchanged — worker, branch, session, review, Accept — with no account anywhere. Clear the agent command when you want the real one back.
Click a task's title and you land on its own page — the row answers what is waiting for me?, the page answers what actually happened?
You get its coordinates (branch, base, machine), the full prompt, and then two layers that are not the same kind of fact:
- Session — the timeline, filling in while the agent works, if the project captures it. A tool call and its result are one line, folded onto what the call was about (the path, the command) rather than its JSON. The "live" dot follows the stream itself: a session that has ended stops claiming to be live even before the task row has caught up.
- What ran — every command Spunto ran for the task, with its output: the branch setup, the agent session, the accept or drop command. Each shows the exact shell that ran, where, how long it took and what it printed. Failed and still-running ones are expanded.
Over the API, the same thing:
curl -H "Authorization: Bearer spk_..." \
https://spunto.net/api/orgs/{orgId}/projects/{projectId}/tasks/{taskId}/commandsOldest first — the order things happened in.
On a phone, the same thing has a shape of its own: the list of conversations fills the screen, and opening one takes it over — a full-screen view with its own back button, the reply box glued above the keyboard, and everything the desktop keeps in a sidebar (branch, machine, prompt, what ran, Drop) one tap away in a details sheet.
There is still no diff view: that needs a brick the product does not have yet, and a page that faked it would be worse than one that shows you exactly what it knows.
curl -H "Authorization: Bearer spk_..." \
https://spunto.net/api/orgs/{orgId}/projects/{projectId}/tasks/{taskId}{
"id": "k3f9x2...",
"title": "Fix the login that breaks on Safari",
"state": "in-review",
"branch": "task/fix-the-login-that-breaks-on-safari-a1b2c3",
"baseBranch": "main",
"workerId": "wkr_abc123",
"commandId": "cmd_def456",
"outcomeUrl": "https://github.com/acme/app/pull/262",
"outcomeLabel": "#262",
"meta": {},
"startedAt": "2026-08-30T09:14:02.000Z",
"completedAt": null
}outcomeUrl and outcomeLabel are whatever the work produced — a pull request, a merge request,
a preview deployment. Nothing in the model is specific to one forge.
commandId is the agent session, readable as an ordinary
worker command: its output is there while it runs and after it
finishes. Because the session lives in the worker rather than in the API, it survives restarts on
our side entirely.
This is the first slice, and it stops where it stops:
- no conversation — you can watch a session, but not reply to it
- no interactive permissions — which is why the default harness runs with them skipped
- no diff view — the review happens on your forge
- no notifications
Each of those hangs off this object rather than replacing it, which is why it landed first.
