Skip to content

Projects

A Project is the unit of work in the Studio: one brief (or generator run), one task graph, one team, one set of outputs.

ProjectStatus = 'draft' | 'planning' | 'running' | 'review' | 'done' | 'failed' | 'cancelled'
GET /api/studio/projects → Project[] (yours, newest first)
GET /api/studio/projects/:id → ProjectDetail (project + tasks + team + agents)
POST /api/studio/projects/:id/cancel → Project
GET /api/studio/projects/:id/trace → TraceEvent[] (up to 2,000, oldest first)

/studio/:id (Project.tsx) renders the whole run live: a status pill, the task graph, a scrolling trace stream, and the outputs panel — all driven by the same project.updated / task.updated / trace WebSocket events the API publishes as the run progresses, not by polling.

Crews: staffing a project with people you have already chosen

Before assembly runs, the project asks whether a crew was bound to it. A crew is an ordinary group — a named set of beings with roles that outlives any one project.

GET /api/projects/:projectId/crew → the binding, the group, its seats, and whose
people are about to be given work
PUT /api/projects/:projectId/crew { groupId, includeDescendants? }

Choosing a crew replaces any earlier choice — one crew per project — and {"groupId": null} clears it and restores automatic assembly, which is what every project that has never been given one uses. You must own the project and be of the group.

Choosing starts nothing: the next run reads the row. And binding a company rather than a crew is allowed, since a company and a crew are the same kind of row — but the pool does not widen by itself. includeDescendants defaults to false, is opt-in per project, and needs lead-or-owner of the bound group to turn on, because a company’s whole tree is a spend surface as much as it is a roster.

GET /api/groups/:id/work reads the other direction: what this crew has actually done, across every project it was chosen for, and what each seat did in them.

Team assembly

With no crew bound — or to fill roles the crew’s seats do not cover — assemble() (packages/agents/src/team.ts) builds one agent per distinct role in the plan:

  1. Reuse first — if you have a saved Agent whose role matches and who already carries at least 60% of the skills that role’s tasks need, it’s reused as-is.
  2. Otherwise, an ephemeral agent is created — a role-appropriate persona (name, voice, default skills) from a small built-in table, given every skill its tasks need plus the default agent skills (from DEFAULT_AGENT_SKILLS), and a full compiled system prompt. If the Souls package is available, the ephemeral agent gets a real, deterministically-generated Soul (no model call) compiled in “agent” mode; otherwise it falls back to a plain persona-prompt template.
  3. A lead is chosen — an explicit lead-role agent if one exists, otherwise whoever has the most tasks.

Ephemeral agents created this way are real Agent rows (beingId: null distinguishes them from full citizen agents) — visible in the project’s agents[] and reusable on a future project if you save them, though nothing does that automatically today.

Running the graph

run() (packages/agents/src/runner.ts) executes the DAG with bounded concurrency (3 tasks at once by default): a task becomes ready once every task it dependsOn is done; ready tasks run through a full tool-calling loop with the skills its role and task allow, plus the dependency outputs of earlier tasks as context (truncated to a 14,000-character budget, 5,000 per dependency).

Every task attempt is retried once on failure before being marked failed; if a task’s dependency never completes, it’s marked blocked rather than left pending forever, and the whole run still finishes — independent branches of the graph keep going even while one branch has failed.

Only a leaf task (one nothing else depends on — an actual deliverable) has its output written to the project’s top-level folder; every other task’s result is passed to its dependants as context, never written to disk. This is a deliberate fix, not the original behaviour: the first real end-to-end run wrote every task’s inline reply as a file, so a four-step research → outline → draft → polish generator produced an outline, a draft and a finished post sitting side by side as three rival “final” documents — see Outputs for what the QA reviewer made of that. Dependants read an earlier step from its stored task output, never from disk, so an intermediate step loses nothing by staying unwritten.

Inside a task’s own file tool calls (files_write/files_read/files_list), paths are always project-scoped: they resolve relative to workspace/projects/<projectId>/, sandboxed so a task can never write outside its own project directory, whatever scope a general files.* skill might otherwise expose.

The task graph, visually

TaskGraph.tsx renders every Task (title, role, status, dependency arrows) as a live graph; a node’s border pulses while running, greys while blocked, and shows its assigned agent’s avatar. TraceStream.tsx renders every TraceEventplan, think, tool.call, tool.result, message, output, error, done — as a readable scroll of what each agent actually did, in order, with tool inputs/outputs previewed (never a raw dump: previews are capped at 2,500 characters by the runner before they’re even stored).

Cost

Every task’s costUsd accumulates into the project’s total, which is what you see on the status pill and what the budget guards check against LLM_BUDGET_PER_PROJECT_USD. The final done trace line always states it plainly: “Done. 4 of 4 tasks finished; 3 outputs; about $0.0284.”

Cancelling

POST /api/studio/projects/:id/cancel → Project

Cancels every task that hasn’t finished (pending/ready/running/blockedcancelled), aborts the in-flight tool loop via its AbortSignal, and stops the run — a cancelled project never proceeds to QA.

What happens after every task finishes

Once the graph settles with at least one real output, Outputs covers assembly and the QA review that runs before a project is marked done.

Editing the plan, and gates

A plan is not read-only once it starts.

PATCH /api/studio/projects/:id/tasks/:taskId { status?, assignee?, dependsOn? }
PUT /api/studio/projects/:id/tasks/:taskId/gate { question? }
DELETE /api/studio/projects/:id/tasks/:taskId/gate
PUT /api/studio/projects/:id/due { taskId?, dueAt }

assignee takes being:<id>a human — or agent:<id>, or null. A step assigned to a person is not run by the crew; it waits for them, and finishing it unblocks whatever depended on it. That is how “manage a whole team” comes to include people.

A gate arms a human decision in front of a step’s dependants. The decision itself is an ordinary decision Signal on your desktop, answered the same way every other Signal is — not a second approval mechanism. Editing is refused (409) while a run holds the project, and refused (400) for a dependency loop or a step already held behind an unanswered gate.

A due date goes in a side table rather than on the task row, because the runner rewrites a task’s output wholesale on every attempt and would destroy a date you had set by hand. taskId: null sets the date on the project itself.

Everything waiting on you across every project — gates included — is collected at /work; see Architecture.

If the API restarts mid-run

A run’s live registry (which project a task loop is actually working on) lives in process memory, not the database, so an API restart mid-project cannot hand the work back to anything. What it does instead is take a durable claim: startProject() writes a row to studio_run_claims before it begins and heartbeats it every 15 seconds (apps/api/src/modules/studio/claims.ts); a reaper runs at boot and every 60 seconds after, and fails any project whose claim is stale or whose holder process is provably gone (checked by pid, not by timeout alone, so a tsx watch reload reclaims its own stranded runs in about a second rather than waiting out a timer). A project caught this way gets a plain trace line, its unfinished tasks are marked failed, and its spend is reconciled against llm_calls so the row doesn’t understate what was actually billed.

What survives: tasks that had already reached done keep their status and their output rows; nothing already written to workspace/projects/<id>/ is deleted.

And now you can pick it up again. Two routes, deliberately separate:

GET /api/studio/projects/:id/resume → what continuing would re-run, keep and hold
POST /api/studio/projects/:id/continue → actually do it

The GET does nothing and spends nothing — it tells you which steps would run again, which are already done and would be kept, and which are held behind a gate — so “continue” is a decision you make with the plan in front of you rather than a button you press hopefully. The POST returns 409 with a readable reason when the project cannot be picked up. This replaces the old advice to start a fresh project from the same brief.