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.
| Field | Type | Notes |
|---|
target | number (safeint, > 0) | string (min 1) | |
tolerance | number (≥ 0, ≤ 1) | optional · default 0.2 |
floor | number (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.
| Field | Type | Notes |
|---|
entry | string | optional · default 'apps/web/dist/index.html' |
skill | string | optional · default 'code.scaffold_react_node' |
logChars | number (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
| Field | Type | Notes |
|---|
kind | AuthoredKind | |
min | number (safeint, ≥ 1, ≤ 24) | optional · default 1 |
skill | string | optional |
AuthoredRecord
One thing a run authored, as the authoring skill reported it.
| Field | Type | Notes |
|---|
kind | AuthoredKind | |
id | string | null | optional · default null |
name | string | optional · default '' |
ok | boolean | optional · default true |
note | string | optional · 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.
| Field | Type | Notes |
|---|
file | string | optional |
words | WordBudget | optional |
frontmatter | string[] | optional · default [] |
minSourceLinks | number (safeint, ≥ 0) | optional · default 0 |
requireSections | string[] | optional · default [] |
forbidOpeners | string[] | optional · default [] |
revises | string | optional |
build | BuildArtefact | optional |
authored | AuthoredArtefact | optional |
minRetainedWords | number (≥ 0, ≤ 1) | optional · default 0.85 |
minRetainedLinks | number (≥ 0, ≤ 1) | optional · default 1 |
maxRepairs | number (safeint, ≥ 0, ≤ 2) | optional · default 1 |
GeneratorStep
| Field | Type | Notes |
|---|
id | string | |
title | string | |
role | string | agent role that performs it, e.g. ‘researcher’, ‘writer’, ‘engineer’ |
skills | string[] | skill names allowed |
instruction | string | templated with {{inputs.x}} and {{steps.id.output}} |
tier | ModelTier | optional · default 'cheap' |
dependsOn | string[] | optional · default [] |
outputKind | OutputKind | optional · default 'markdown' |
deliverable | DeliverableContract | optional |
Generator
| Field | Type | Notes |
|---|
id | Id | |
ownerId | Id | |
name | string | |
slug | string | |
description | string | |
icon | string | optional · default '✨' |
inputsSchema | Json | JSON Schema for the input form |
steps | GeneratorStep[] | |
outputKinds | OutputKind[] | |
priceCredits | number | optional · default 0 |
isPublic | boolean | optional · default true |
runs | number | optional · default 0 |
createdAt | ISODate | |
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.
| Field | Type | Notes |
|---|
id | Id | |
ownerId | Id | |
name | string | |
description | string | |
icon | string | optional · default '✨' |
outputKinds | OutputKind[] | |
runs | number | optional · default 0 |
TaskStatus
'pending' | 'ready' | 'running' | 'blocked' | 'done' | 'failed' | 'cancelled'
Task
| Field | Type | Notes |
|---|
id | Id | |
projectId | Id | |
parentId | Id | null | optional · default null |
title | string | |
description | string | optional · default '' |
role | string | |
assigneeAgentId | Id | null | optional · default null |
skills | string[] | optional · default [] |
tier | ModelTier | optional · default 'cheap' |
dependsOn | Id[] | optional · default [] |
status | TaskStatus | optional · default 'pending' |
input | Json | null | optional · default null |
output | Json | null | optional · default null |
error | string | null | optional · default null |
costUsd | number | optional · default 0 |
startedAt | ISODate | null | optional · default null |
finishedAt | ISODate | null | optional · default null |
ProjectStatus
'draft' | 'planning' | 'running' | 'review' | 'done' | 'failed' | 'cancelled'
ProjectOutput
| Field | Type | Notes |
|---|
id | string | |
kind | OutputKind | |
name | string | |
path | string | relative to workspace/projects/<projectId>/ |
url | string | /api/files/project/<projectId>/<path> |
size | number | |
previewUrl | string | null | optional · default null |
Project
| Field | Type | Notes |
|---|
id | Id | |
ownerId | Id | |
generatorId | Id | null | optional · default null |
title | string | |
brief | string | |
inputs | Json | optional · default {} |
status | ProjectStatus | optional · default 'draft' |
outputs | ProjectOutput[] | optional · default [] |
teamId | Id | null | optional · default null |
costUsd | number | optional · default 0 |
createdAt | ISODate | |
updatedAt | ISODate | |
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).
| Field | Type | Notes |
|---|
id | Id | |
ownerId | Id | |
beingId | Id | |
name | string | |
role | string | |
description | string | optional · default '' |
avatar | string | optional · default '🤖' |
skills | string[] | |
tier | ModelTier | optional · default 'cheap' |
systemPrompt | string | optional · default '' |
isPublic | boolean | optional · default false |
createdAt | ISODate | |
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().
| Field | Type | Notes |
|---|
id | Id | |
ownerId | Id | |
name | string | |
role | string | |
description | string | optional · default '' |
avatar | string | optional · default '🤖' |
skills | string[] | |
tier | ModelTier | optional · default 'cheap' |
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.
| Field | Type | Notes |
|---|
name | string | |
role | string | |
description | string | optional · default '' |
avatar | string | optional · default '🤖' |
skills | string[] | |
tier | ModelTier | optional · default 'cheap' |
systemPrompt | string | optional · default '' |
isPublic | boolean | optional · default false |
Team
| Field | Type | Notes |
|---|
id | Id | |
projectId | Id | |
leadAgentId | Id | |
memberAgentIds | Id[] | |
createdAt | ISODate | |
TraceKind
'plan' | 'think' | 'tool.call' | 'tool.result' | 'message' | 'output' | 'error' | 'done'
TraceEvent
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.
| Field | Type | Notes |
|---|
code | 'file' | 'words' | 'frontmatter' | 'sources' | 'sections' | 'voice' | 'regression' | 'empty' | 'authored' | |
message | string | |
DeliverableReport
What the mechanical check made of one step’s artefact. Persisted inside Task.output.
| Field | Type | Notes |
|---|
ok | boolean | |
file | string | null | optional · default null |
words | number (safeint, ≥ 0) | |
sourceLinks | number (safeint, ≥ 0) | |
failures | DeliverableFailure[] | optional · default [] |
repairs | number (safeint, ≥ 0) | optional · default 0 |
revertedTo | string | null | optional · default null |
build | { built: boolean; entry: string | null; log: string } | null | optional · default null |
authored | AuthoredRecord[] | 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.
| Field | Type | Notes |
|---|
verdict | ReviewVerdict | |
summary | string | optional · default '' |
strengths | string[] | optional · default [] |
issues | string[] | optional · default [] |
source | 'qa' | 'contract' | optional · default 'qa' |
at | ISODate | |
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.
| Field | Type | Notes |
|---|
id | string | |
beingId | Id | null | optional · default null |
agentId | Id | null | optional · default null |
kind | BeingKind | null | optional · default null |
name | string | |
handle | string | optional · default '' |
avatar | string | optional · default '🙂' |
role | string | optional · default '' |
canRun | boolean | optional · 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
| Field | Type | Notes |
|---|
taskId | Id | |
projectId | Id | |
question | string | |
status | GateStatus | |
signalId | Id | null | optional · default null |
answer | string | optional · default '' |
decidedAt | ISODate | null | optional · default null |
decidedBy | Id | null | optional · default null |
createdAt | ISODate | |
ResumeStep
What a POST /api/studio/projects/:id/continue would do, without doing it.
| Field | Type | Notes |
|---|
taskId | Id | |
title | string | |
why | string | |
ResumePlan
| Field | Type | Notes |
|---|
canResume | boolean | |
reason | string | optional · default '' |
rerun | ResumeStep[] | optional · default [] |
keep | ResumeStep[] | optional · default [] |
held | ResumeStep[] | optional · default [] |
ProjectBoard
Additive management view of a project: who holds what, and what is waiting on a person.
| Field | Type | Notes |
|---|
gates | TaskGate[] | optional · default [] |
assignees | { id, beingId, agentId, kind, name, handle, avatar, role, canRun, taskId }[] | optional · default [] |
citizens | Citizen[] | optional · default [] |
ProjectDetail
| Field | Type | Notes |
|---|
id | Id | |
ownerId | Id | |
generatorId | Id | null | optional · default null |
title | string | |
brief | string | |
inputs | Json | optional · default {} |
status | ProjectStatus | optional · default 'draft' |
outputs | ProjectOutput[] | optional · default [] |
teamId | Id | null | optional · default null |
costUsd | number | optional · default 0 |
createdAt | ISODate | |
updatedAt | ISODate | |
tasks | Task[] | |
team | Team | null | |
agents | Agent[] | |
review | ProjectReview | null | optional · default null |
deliverables | { ok, file, words, sourceLinks, failures, repairs, revertedTo, build, authored, taskId, step }[] | optional · default [] |
gates | TaskGate[] | optional · default [] |
assignees | { id, beingId, agentId, kind, name, handle, avatar, role, canRun, taskId }[] | optional · default [] |
citizens | Citizen[] | optional · default [] |
ProjectSummary
A project as the gallery lists it: the row plus its verdict, so nothing green-ticks a rejected build.
| Field | Type | Notes |
|---|
id | Id | |
ownerId | Id | |
generatorId | Id | null | optional · default null |
title | string | |
brief | string | |
inputs | Json | optional · default {} |
status | ProjectStatus | optional · default 'draft' |
outputs | ProjectOutput[] | optional · default [] |
teamId | Id | null | optional · default null |
costUsd | number | optional · default 0 |
createdAt | ISODate | |
updatedAt | ISODate | |
review | ProjectReview | null | optional · default null |
CreateProjectRequest
| Field | Type | Notes |
|---|
title | string | optional |
brief | string (min 1) | |
generatorId | Id | optional |
inputs | Json | optional |