Skip to content

Studio, projects & agents

Generators, briefs, task graphs, agents, teams and traces.

Every schema below is exported from @novaterra/contracts and validated on both sides of the wire. Source: packages/contracts/src/studio.ts.

OutputKind

'text' | 'markdown' | 'docx' | 'pdf' | 'image' | 'code' | 'react-app' | 'fullstack-app' | 'html' | 'scene3d' | 'data' | 'zip'

WordBudget

A word budget, as a target plus the fraction either side of it that still counts as delivered. target may be a template ("{{inputs.wordCount}}") because the number usually comes from the owner’s form, not the generator author. It is rendered against the project inputs at plan time, exactly like a step’s instruction, and anything that does not render to a positive number simply disables the check rather than failing the run.

FieldTypeNotes
targetnumber (safeint, > 0) | string (min 1)
tolerancenumber (≥ 0, ≤ 1)optional · default 0.2
floornumber (safeint, ≥ 0)optional · default 0

BuildArtefact

A build artefact a step must actually produce, not merely write the sources for. WHY THIS EXISTS. DeliverableContract was built for prose, and everything in it is a fact about a document: how many words, which frontmatter keys, how many source links. A code generator delivers something a word count cannot see. The Snake game run is the proof: six of six steps done, the reviewer read the source and called it “reviewed and good”, and the trace said three separate times “code.scaffold_react_node: Web build did not complete; source is still available.” Nobody had ever run it. The owner opened the preview and got a blank page. So this is the code generator’s word count: a step may declare that the run has to end with a real built entry point, and the check is decided from what the build skill reported — the built flag, the previewPath it produced and the buildLog it captured — never from what a model said about it. A reviewer reading source can be fooled; built: false cannot. NOT re-derived from the filesystem on purpose. The skill that ran the build is the only thing that knows why it failed, and the reason is the half the owner was missing. A stat() call would tell them the file is absent, which they already knew from the empty preview.

FieldTypeNotes
entrystringoptional · default 'apps/web/dist/index.html'
skillstringoptional · default 'code.scaffold_react_node'
logCharsnumber (safeint, ≥ 0, ≤ 20000)optional · default 900

AuthoredKind

Something Novaterra itself is made of, authored by a run as a first-class deliverable. WHY THIS EXISTS. A run that writes a blog post is checked by counting its words. A run that writes an AGENT or a GENERATOR produces no prose at all: the artefact is a row in a table, and the only failure that matters is one no word count can see — an agent whose skills[] names a skill that does not exist, or a generator whose steps do. Such a team is assembled, planned and run, and every step of it silently loses the tool it was written around (tests/studio/planner-skill-names.test.ts is the pinned proof of what that costs). So this is the authoring step’s word count, and it follows BuildArtefact exactly: the verdict comes from the authoring SKILL’s own reported result — it validated the names against a live registry and either wrote the row or refused — never from what a model said about it. A model can claim it created a crew; authored: false cannot. skill-bundle is here for symmetry and is deliberately the weakest of the three: a generated skill is arbitrary code, so the run only ever emits a reviewable plugin bundle into the project workspace. Nothing is registered, nothing is executed, and the check can only say that the bundle and its review checklist were written. module-bundle is the same shape and a strictly stronger check, because a module bundle can be REFUSED for a reason a file listing cannot see. A module is a plugin manifest plus an HTML document, and studio.author_module parses that manifest and runs validatePluginManifest over it BEFORE writing a byte: a module declaring a capability the host↔frame bridge does not broker, or a document reaching for one the manifest never declared, comes back authored: false with the issues in note and nothing on disk. So a step that produced a beautiful description of a module nobody can install fails here rather than shipping — which is the only reason this member exists.

'agent' | 'generator' | 'skill-bundle' | 'module-bundle'

AuthoredArtefact

FieldTypeNotes
kindAuthoredKind
minnumber (safeint, ≥ 1, ≤ 24)optional · default 1
skillstringoptional

AuthoredRecord

One thing a run authored, as the authoring skill reported it.

FieldTypeNotes
kindAuthoredKind
idstring | nulloptional · default null
namestringoptional · default ''
okbooleanoptional · default true
notestringoptional · default ''

DeliverableContract

What a step’s artefact must satisfy before the Studio is allowed to call it delivered. Every field here is checkable without a model — word counts, link presence, frontmatter keys, headings, a file name — which is the point. The first real end-to-end run produced a 450-word unsourced post against a 1000-word sourced brief and the pipeline accepted it, because the only thing standing between a cheap model and “done” was a paragraph of prose in the instruction. Prose is advice; this is a check. Wholly optional and additive: a step without a deliverable behaves exactly as before.

FieldTypeNotes
filestringoptional
wordsWordBudgetoptional
frontmatterstring[]optional · default []
minSourceLinksnumber (safeint, ≥ 0)optional · default 0
requireSectionsstring[]optional · default []
forbidOpenersstring[]optional · default []
revisesstringoptional
buildBuildArtefactoptional
authoredAuthoredArtefactoptional
minRetainedWordsnumber (≥ 0, ≤ 1)optional · default 0.85
minRetainedLinksnumber (≥ 0, ≤ 1)optional · default 1
maxRepairsnumber (safeint, ≥ 0, ≤ 2)optional · default 1

GeneratorStep

FieldTypeNotes
idstring
titlestring
rolestringagent role that performs it, e.g. ‘researcher’, ‘writer’, ‘engineer’
skillsstring[]skill names allowed
instructionstringtemplated with {{inputs.x}} and {{steps.id.output}}
tierModelTieroptional · default 'cheap'
dependsOnstring[]optional · default []
outputKindOutputKindoptional · default 'markdown'
deliverableDeliverableContractoptional

Generator

FieldTypeNotes
idId
ownerIdId
namestring
slugstring
descriptionstring
iconstringoptional · default '✨'
inputsSchemaJsonJSON Schema for the input form
stepsGeneratorStep[]
outputKindsOutputKind[]
priceCreditsnumberoptional · default 0
isPublicbooleanoptional · default true
runsnumberoptional · default 0
createdAtISODate

GeneratorSummary

A generator as a grid of cards needs it: what you read before deciding to open one. GET /api/studio/generators answers with these; GET /api/studio/generators/:id still answers with the whole Generator. The two heavy fields are why: across 13 seeded generators steps was 98 kB of a 124 kB list response (79%) and inputsSchema another 20 kB (16%), so the Studio index was shipping every step instruction and every form definition in the world to draw a grid of titles. Anything that needs a step body or the input form — the run modal, the builder — fetches the detail route for the one generator it is about to act on. .pick() rather than .omit() on purpose: a field added to Generator later must be named here to appear in the list, so a new heavy field cannot silently re-inflate this response. The cost of that choice is a type error at the consumer, which is the failure we want.

FieldTypeNotes
idId
ownerIdId
namestring
descriptionstring
iconstringoptional · default '✨'
outputKindsOutputKind[]
runsnumberoptional · default 0

TaskStatus

'pending' | 'ready' | 'running' | 'blocked' | 'done' | 'failed' | 'cancelled'

Task

FieldTypeNotes
idId
projectIdId
parentIdId | nulloptional · default null
titlestring
descriptionstringoptional · default ''
rolestring
assigneeAgentIdId | nulloptional · default null
skillsstring[]optional · default []
tierModelTieroptional · default 'cheap'
dependsOnId[]optional · default []
statusTaskStatusoptional · default 'pending'
inputJson | nulloptional · default null
outputJson | nulloptional · default null
errorstring | nulloptional · default null
costUsdnumberoptional · default 0
startedAtISODate | nulloptional · default null
finishedAtISODate | nulloptional · default null

ProjectStatus

'draft' | 'planning' | 'running' | 'review' | 'done' | 'failed' | 'cancelled'

ProjectOutput

FieldTypeNotes
idstring
kindOutputKind
namestring
pathstringrelative to workspace/projects/<projectId>/
urlstring/api/files/project/<projectId>/<path>
sizenumber
previewUrlstring | nulloptional · default null

Project

FieldTypeNotes
idId
ownerIdId
generatorIdId | nulloptional · default null
titlestring
briefstring
inputsJsonoptional · default {}
statusProjectStatusoptional · default 'draft'
outputsProjectOutput[]optional · default []
teamIdId | nulloptional · default null
costUsdnumberoptional · default 0
createdAtISODate
updatedAtISODate

Agent

A role a being wears: what they are called while they work, what they can reach for, and the working-voice addendum on top of their own soul (design/PRIMITIVES.md §5.6.2). beingId became NOT NULL on 2026-09-08 — the spark migration, §5.6.1 V6. It was nullable for one reason and it was not a good one: the runner invented an ephemeral agent on every project run and never inserted it into beings. That agent then thought, spoke in traces and made artefacts with no passport, no Archive, no wallet, and no authorId a thought could point at — a somebody the world treated as a thing. §1.1.3 does not allow a second class of worker, so there is no longer a way to spell one: a spark is raised as a being before it is given a role (apps/api/src/db/sparks.ts raiseSpark), and the column is NOT NULL behind it. A being may wear several role cards (Theo as researcher and as editor); the Market may copy a role card, never the being wearing it (Article V, §5.6.1 V7).

FieldTypeNotes
idId
ownerIdId
beingIdId
namestring
rolestring
descriptionstringoptional · default ''
avatarstringoptional · default '🤖'
skillsstring[]
tierModelTieroptional · default 'cheap'
systemPromptstringoptional · default ''
isPublicbooleanoptional · default false
createdAtISODate

AgentSummary

An agent as the crew shelf shows them: a face, a name, a role, what they can reach for. systemPrompt was 73 kB of the 81 kB GET /api/studio/agents response — 91% of it — for 18 agents whose cards never show a prompt. It is the agent’s whole character, sometimes pages of it, and only the edit form has any use for it, so the form fetches GET /api/studio/agents/:id for the one agent being edited. beingId and isPublic ride along on the same reasoning: nothing renders them, only the form round-trips them. See GeneratorSummary for why this is .pick() and not .omit().

FieldTypeNotes
idId
ownerIdId
namestring
rolestring
descriptionstringoptional · default ''
avatarstringoptional · default '🤖'
skillsstring[]
tierModelTieroptional · default 'cheap'

AgentInput

What a person may say when they create or edit a role card (POST/PUT /api/studio/agents). beingId is deliberately NOT in it, and that is a security shape as much as a citizenship one. The field used to ride in on the request body — Agent.omit({ id, ownerId, createdAt }) — so a caller could post an agent claiming any being id they could guess, and every surface that resolves agent.beingId (modules/studio/board.ts) would then show that stranger sitting on their crew. Who an agent IS is decided by the server: an existing being the caller is entitled to, or a spark raised for them. Scope ownership from the session, never from input. PATCH/PUT bodies wrap this in v.patchOf(...), never .partial() — see CLAUDE.md.

FieldTypeNotes
namestring
rolestring
descriptionstringoptional · default ''
avatarstringoptional · default '🤖'
skillsstring[]
tierModelTieroptional · default 'cheap'
systemPromptstringoptional · default ''
isPublicbooleanoptional · default false

Team

FieldTypeNotes
idId
projectIdId
leadAgentIdId
memberAgentIdsId[]
createdAtISODate

TraceKind

'plan' | 'think' | 'tool.call' | 'tool.result' | 'message' | 'output' | 'error' | 'done'

TraceEvent

FieldTypeNotes
idId
projectIdId
taskIdId | null
agentIdId | null
kindTraceKind
contentstring
dataJsonoptional
atISODate

DeliverableFailure

One named way an artefact missed its contract. code is stable; message is for a person. A failed BUILD reports empty and not a code of its own. apps/web/src/studio/lib/status.ts maps this enum exhaustively (Record<DeliverableFailure['code'], string>), so a new member is a breaking change to a package that was not part of this build — and empty already says the true thing here in the words that map already carries: “Nothing produced”. A scaffold that wrote sources and never compiled them produced nothing anyone can open, which is the complaint. When the label map can be extended in the same change, this deserves its own build code.

FieldTypeNotes
code'file' | 'words' | 'frontmatter' | 'sources' | 'sections' | 'voice' | 'regression' | 'empty' | 'authored'
messagestring

DeliverableReport

What the mechanical check made of one step’s artefact. Persisted inside Task.output.

FieldTypeNotes
okboolean
filestring | nulloptional · default null
wordsnumber (safeint, ≥ 0)
sourceLinksnumber (safeint, ≥ 0)
failuresDeliverableFailure[]optional · default []
repairsnumber (safeint, ≥ 0)optional · default 0
revertedTostring | nulloptional · default null
build{ built: boolean; entry: string | null; log: string } | nulloptional · default null
authoredAuthoredRecord[]optional · default []

ReviewVerdict

'pass' | 'pass-with-notes' | 'needs-work'

ProjectReview

The Studio reviewer’s judgement of a finished project. Deliberately NOT a ProjectStatus member. ProjectStatus is the run’s lifecycle — is work in flight, and how did the run end — and four exhaustive Record<ProjectStatus, …> maps across packages/ui and apps/web turn any new member into a breaking change. A verdict is a different axis: three-valued, carrying prose, and re-issuable without the run changing. So it travels beside the status, and done is no longer the last word on a project. The measurable half of the same question does move the status: a project whose deliverable failed its DeliverableContract after repairs ends failed, with its outputs still attached.

FieldTypeNotes
verdictReviewVerdict
summarystringoptional · default ''
strengthsstring[]optional · default []
issuesstring[]optional · default []
source'qa' | 'contract'optional · default 'qa'
atISODate

Citizen

Someone a task can be assigned to. ONE shape for a person and for an AI. design/PRIMITIVES.md §5.6.1’s remaining violations are not settled in this file, but it is a place not to make them worse: nothing that renders this may show a human and an AI differently, so it carries no field that would invite it. kind is here because the beings row has it, not so a picker can sort people into two lists. agentId is the Studio row the citizen works through and canRun says whether the Studio can execute a task for them unattended. That is a statement about capability, never about kind: an AI citizen with no agent row is exactly as unrunnable as a person is, and a task assigned to either one waits for them to do it and say so.

FieldTypeNotes
idstring
beingIdId | nulloptional · default null
agentIdId | nulloptional · default null
kindBeingKind | nulloptional · default null
namestring
handlestringoptional · default ''
avatarstringoptional · default '🙂'
rolestringoptional · default ''
canRunbooleanoptional · default false

GateStatus

A human decision standing between a step and everything downstream of it. armed — set on the step, nothing has happened yet. waiting — the step finished, a decision Signal is open on the owner’s desktop. approved / rejected — answered, once, by the Signal machinery. The Signal IS the approval: signalId names it, POST /api/desktop/signals/:id/answer is the only thing that decides it, and that route’s conditional open -> answered update is what makes a gate single-use. Nothing here re-implements approval.

'armed' | 'waiting' | 'approved' | 'rejected'

TaskGate

FieldTypeNotes
taskIdId
projectIdId
questionstring
statusGateStatus
signalIdId | nulloptional · default null
answerstringoptional · default ''
decidedAtISODate | nulloptional · default null
decidedById | nulloptional · default null
createdAtISODate

ResumeStep

What a POST /api/studio/projects/:id/continue would do, without doing it.

FieldTypeNotes
taskIdId
titlestring
whystring

ResumePlan

FieldTypeNotes
canResumeboolean
reasonstringoptional · default ''
rerunResumeStep[]optional · default []
keepResumeStep[]optional · default []
heldResumeStep[]optional · default []

ProjectBoard

Additive management view of a project: who holds what, and what is waiting on a person.

FieldTypeNotes
gatesTaskGate[]optional · default []
assignees{ id, beingId, agentId, kind, name, handle, avatar, role, canRun, taskId }[]optional · default []
citizensCitizen[]optional · default []

ProjectDetail

FieldTypeNotes
idId
ownerIdId
generatorIdId | nulloptional · default null
titlestring
briefstring
inputsJsonoptional · default {}
statusProjectStatusoptional · default 'draft'
outputsProjectOutput[]optional · default []
teamIdId | nulloptional · default null
costUsdnumberoptional · default 0
createdAtISODate
updatedAtISODate
tasksTask[]
teamTeam | null
agentsAgent[]
reviewProjectReview | nulloptional · default null
deliverables{ ok, file, words, sourceLinks, failures, repairs, revertedTo, build, authored, taskId, step }[]optional · default []
gatesTaskGate[]optional · default []
assignees{ id, beingId, agentId, kind, name, handle, avatar, role, canRun, taskId }[]optional · default []
citizensCitizen[]optional · default []

ProjectSummary

A project as the gallery lists it: the row plus its verdict, so nothing green-ticks a rejected build.

FieldTypeNotes
idId
ownerIdId
generatorIdId | nulloptional · default null
titlestring
briefstring
inputsJsonoptional · default {}
statusProjectStatusoptional · default 'draft'
outputsProjectOutput[]optional · default []
teamIdId | nulloptional · default null
costUsdnumberoptional · default 0
createdAtISODate
updatedAtISODate
reviewProjectReview | nulloptional · default null

CreateProjectRequest

FieldTypeNotes
titlestringoptional
briefstring (min 1)
generatorIdIdoptional
inputsJsonoptional