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.

Note

Tasks sit on top of workers. Everything a worker gives you — the isolated container, the cloned repos, the injected secrets — a task gets too. It just stops being the thing you think about.

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}/tasks

The 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.

StateWhat it means
queuedwaiting for a machine
runningthe agent session is alive
in-reviewthe session ended cleanly — there is something to judge
doneyou accepted it
failedthe 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.

Note

A task stops advancing once its session ends. It waits in review until you press Accept or Drop — and nothing else moves it. Merging the pull request on your forge does not: your worker stays yours, and the task stays in review until you say otherwise. Accepting is where your own merge command runs.

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.

Tip

Workers created by tasks carry the task tag. Tag one of your own workers task to donate it to the pool; untagged workers are never taken.

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 failed with 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.

SettingEmpty meansSet it to
Agent commandClaude Codeany CLI that reads its prompt on stdin
Session streamnothing captured but the stdout tailClaude Code, or your own JSONL — see below
Accept commandAccept just marks the task donegh pr merge, glab mr merge, a deploy…
Drop commandjust kill the sessionwhatever cleanup you need
Worker during reviewStop itKeep 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.

Note

Still missing: interactive permissions (which is why the default harness skips them) and interrupting a session mid-flight. Both need a live stdin inside the worker.

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_STATUS

Your 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.

Note

The SPUNTO_TASK_* variables are applied last. A secret sharing one of their names cannot override it — they are the contract your scripts rely on.

The only contract is that your command reads the prompt on standard input:

my-agent --headless --yes

Spunto 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 sessionThe task
is runningrunning
ended badlyfailed
ended cleanlyin-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.

Note

A live task's state is recomputed when someone reads it, and every 30 seconds in the background otherwise. That floor is what lets a task reach review — and therefore park its container under Stop it — with the dashboard closed.

# Accept command
gh pr merge "$SPUNTO_TASK_BRANCH" --squash --delete-branch
 
# Drop command
gh pr close "$SPUNTO_TASK_BRANCH" --delete-branch

These 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.

Tip

Keep the git identity local to the repository and the origin inside /workspace, as above. Both then live in the workspace volume, so a worker recreated on the same volume still finds its branches.

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 ranevery 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}/commands

Oldest first — the order things happened in.

Note

Commands you ran in the same worker are deliberately absent — those belong to the worker, not to the task.

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.