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
What a Studio run stamps on an artefact it produced. audio and video were added on 2026-09-15, and the reason is worth keeping, because the usual answer to “should this be a new kind?” here is NO — core.deck was added to the viewer registry with a thirteenth kind deliberately withheld in favour of “a narrow, honest heuristic rather than a new OutputKind” (apps/web/src/files/registry.ts). That precedent holds when the existing kind is TRUE and merely broad: a deck really is html. It does not reach here, because no member of this list was true of an audio file. Council 015’s viewers × OutputKind crossing (design/THE-CROSSINGS.md §5.4) found .mp3 and .mp4 labelled text — not broad, WRONG, and by the fallthrough at kindForFile rather than by anyone’s decision. Two first-party skills write those files into a workspace today (media.speak, media.video), the archive is a kind-faceted, kind-iconed surface over exactly those artefacts, and assembleProject’s own ranking reads the kind to tell a deliverable from a helper file. The nearest existing name, data, is what tests/contracts/capability-writer-seam.test.ts calls its own “catch-all” — a word that admits it is standing in for a name that did not exist. The tree already separates these two jobs one layer down: ModelTier carries speech, transcribe and video as distinct tiers, “different jobs entirely”.
'text' | 'markdown' | 'docx' | 'pdf' | 'image' | 'audio' | 'video' | '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 |
perUnit | number (> 0) | optional |
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 |
assertions | { name, run, expect, because }[] | optional · default [] |
StepEngine
How a step produces its artefact — which rung it runs on.
'llm' | 'template' | 'skill' | 'compose'
TemplateRender
One deterministic render: a template, and the name its output is bound to. The renderer is deliberately not named here. The old estate hard-coded Handlebars and then grew a second engine (sql+sqlite) within the same tree, so the template dialect is an implementation detail of the runner and must not be frozen into the contract.
| Field | Type | Notes |
|---|---|---|
template | string (min 1) | |
outputKey | string (min 1) | |
inline | string (max 64000) | null | optional · default null |
escape | 'html' | 'none' | optional |
SkillCall
L1 RUN: the one skill a step invokes, and the arguments it invokes it with. ==================== WHERE THE ARGUMENTS COME FROM ==================== An llm step hands a rendered instruction to a model and the MODEL decides the arguments. A skill step has no model, so the arguments have to come from somewhere declarative. They come from exactly the vocabulary engine: 'template' already uses and nowhere else — {{inputs.x}}, {{steps.<id>.output}}, {{brief}}, {{title}}, over the step’s resolved inputs — because a second way of saying “the client’s name” would be a second place for the two to disagree. Two rules the runner applies to every string in args (resolveSkillArgs, packages/agents/src/skill-step.ts): a string that is EXACTLY one tag ▸ the referenced VALUE is passed through with its type intact. "{{inputs.taxRate}}" reaches the skill as the number 0, not as “0”, so a skill whose schema wants a number is callable at all. anything else ▸ rendered to a string by the same renderer, with HTML escaping OFF. A skill argument is a value passed to a typed function, not text going into a page; escaping it would turn “Acme & Co” into “Acme & Co” silently. An unknown variable throws, exactly as it does in a template. A half-filled ARGUMENT is worse than a half-rendered document, because a document shows the hole and an argument does not. ==================== WHAT THIS DELIBERATELY CANNOT EXPRESS ==================== There is no expression language, no helper and no arithmetic here — the same refusal the renderer makes, for the same reason. So a skill call whose arguments need a TRANSFORM of a value (split this text into lines, sum these numbers, pick the ones that parsed) cannot be declared, and a skill call whose arguments need JUDGEMENT must not be. Both are L2 and belong on llm; the gate is the one ./promote.ts already draws — values may vary, shape may not. ==================== THE GATE IS NOT BYPASSED ==================== The runner invokes through SkillRegistry.invoke() with no approved option, which is the same call toToolSpecs() makes for a model’s tool call. A world-acting skill named here is therefore refused and put in front of the owner exactly as it would be if a model had asked for it — the step suspends and resumes on the answer. A generator author naming email.send here buys no consent that an agent naming it would not have had.
| Field | Type | Notes |
|---|---|---|
skill | string (min 1) | |
args | Record<string, Json> | optional · default {} |
outputPath | string | optional · default '' |
LadderFloor
The LOWEST rung the planner may ever move this step to. This is the author’s declaration and the planner may not go below it. A step marked L3 is saying this needs invention every time — a research question whose approach IS the work, and which must never be quietly promoted to a template that would answer it the same way twice. Default L2: an ordinary prose step may be moved onto a local model (cheaper, and free on a node with its own hardware) but may NOT be turned into a template without its author saying so, because templating changes the shape of the output and not merely its price.
'L0' | 'L1' | 'L2' | 'L3'
StepFanOut
ONE STEP, MANY ARTEFACTS — a template and a table of rows. ==================== WHY A STEP FANS OUT AND NOT A RUN (R2) ==================== design/ISSUE-MANY-FROM-ONE.md §2 put three shapes on the table and §8 R2 chose this one. A skill that writes N files (ⓐ) was refused because the step hands back exactly one path (runner.ts, the engine: 'skill' branch), so ninety-nine of a hundred certificates would fall to the assembly sweep — which indexes them with taskId: null, authorBeingId: null. That is the precise condition Agent.beingId was made NOT NULL to prevent, on the one artefact class where who issued this is the subject. A certificate with no issuer is not a certificate. N projects (ⓒ) was refused because a hundred certificates would be a hundred Studio rows, a hundred ladder reports and a hundred entries in the falling-cost screen for ONE act. So the fan-out is a property of the STEP. One project, one plan, one ladder report, one task row, N artefacts — each carrying the row it came from, each checked against the contract, and every one of them indexed with the task and the author, which is the whole point. Reopens if somebody genuinely wants each artefact to have its own project — a bursary with its own approval trail, say. Then ⓒ was right and the step is the wrong unit.
| Field | Type | Notes |
|---|---|---|
rows | string (min 1, max 200) | |
as | string (regex) | optional · default 'row' |
max | number (safeint, ≥ 1, ≤ 1000) | optional · default 1000 |
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 |
engine | StepEngine | optional · default 'llm' |
renders | TemplateRender[] | optional · default [] |
call | SkillCall | optional |
forEach | StepFanOut | optional |
floor | LadderFloor | optional · default 'L2' |
needs | Record<'structured' | 'tools' | 'longContext' | 'vision' | 'sceneLayout' | 'audioIn' | 'audioOut' | 'multilingual', 1 | 2 | 3> | optional · default {} |
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 |
GeneratorShelf
Where a generator sits on the shelf, and why in the run’s own words. why is a SENTENCE and not a boolean for the reason refusedBecause in capabilities.ts is one: the reason is the useful part. Jobs’s whole argument for shelving rather than deleting is that the product knows the artefact is bad — a card that says only “unproven” has thrown away the knowledge and kept the stigma. It is the failing run’s own error text, never a summary assembled in a surface. refused and unwitnessed must not collapse into one word. A run that measured something and did not deliver is a different fact from no run having measured anything — four generators here have never been run by any sweep at all, and calling that “fails” would be a claim nobody made.
| Field | Type | Notes |
|---|---|---|
state | 'witnessed' | 'refused' | 'unwitnessed' | |
why | string (min 1) | |
observedAt | string | null | |
measured | number (safeint, ≥ 0) | |
delivered | number (safeint, ≥ 0) | |
fixtures | string[] | optional |
exit | string (min 1) |
GeneratorSummary
| Field | Type | Notes |
|---|---|---|
id | Id | |
ownerId | Id | |
name | string | |
description | string | |
icon | string | optional · default '✨' |
outputKinds | OutputKind[] | |
runs | number | optional · default 0 |
spends | boolean | optional |
shelf | GeneratorShelf | optional |
freeTier | boolean | optional |
StudioFreeTier
What the caller’s own Studio account may run — GET /api/studio/free-tier (Council 038 6-B). tier: 'free' is a person with no way to pay for a model call on this node: not its owner, no active membership with credits, and a node whose owner has not opened its key to everybody. For them ceiling is L1 — the runner refuses any step above it — and generatorIds is the list they can press. tier: 'full' has no ceiling, and the list is still sent so a surface can say which things cost nothing for anybody. ⛔ Asked of the same spend verdict the model guard enforces, never of being.kind (tests/contracts/citizenship.test.ts).
| Field | Type | Notes |
|---|---|---|
tier | 'free' | 'full' | |
ceiling | 'L0' | 'L1' | 'L2' | 'L3' | null | |
mustVerify | boolean | |
generatorIds | Id[] | |
unlockedBy | { planId: string; label: string; price: string; onSale: boolean } | null |
StudioIdeaSource
Where an idea came from — the one fact about the person’s life it answers. goal a goal on their own profile (2-D reads the same list) date something dated in their own calendar within the next three weeks person somebody waiting on them: an unanswered conversation, or an invoice they made project the natural next step after something they finished recently routine a routine they keep, or the time of the month — light suggestions starter nothing to go on yet: the desk’s own first-day starters
'goal' | 'date' | 'person' | 'project' | 'routine' | 'starter'
StudioIdea
One idea the Studio offers under its own question — Council 038 7-L. ⭐ AN IDEA IS A FILLED FORM, NEVER A RUN. Pressing it puts brief in the composer, chooses generatorId and carries inputs into the generator’s form; the form’s price and its Run button are the consent, exactly as for a generator pressed by hand. POST /api/studio/projects spends and has no dry run (CLAUDE.md, “Cost”), so nothing here may reach it. ⛔ generatorId is only ever a generator the shelf calls witnessed (studio/shelf), resolved through the capability catalogue’s own generatorId — never an unwitnessed or refused one. ⛔ why is names and dates only. It never quotes a message: “Priya wrote on Tuesday” and never what Priya wrote. It is composed from the person’s OWN data and is sent to nobody else.
| Field | Type | Notes |
|---|---|---|
id | string (regex) | |
title | string (max 140) | |
brief | string (max 400) | |
generatorId | Id | |
inputs | Record<string, string> | optional · default {} |
why | string (max 160) | |
source | StudioIdeaSource | |
freeTier | boolean | |
expires | ISODate | |
angle | string (regex) | |
reason | string (regex) | |
params | Record<string, string> | optional · default {} |
StudioIdeas
GET /api/studio/ideas — the caller’s own ideas for today. day is the person’s local day the set was drawn for; the set is stable within it and fresh the next. young is true when there was nothing to go on and the ideas are the first-day starters. more is the seam for an optional phrasing pass by a small model: it is OFF unless this node turns it on, and even then it runs only on an explicit press with the price quoted first.
| Field | Type | Notes |
|---|---|---|
day | string (regex) | |
young | boolean | |
ideas | StudioIdea[] | |
more | { available: boolean; priceUsd: number (≥ 0) | null } |
StudioIdeaDismissed
POST /api/studio/ideas/:id/dismiss — the idea is put away for this being, for good.
| Field | Type | Notes |
|---|---|---|
id | string | |
dismissed | true |
Evidence
How a fact about work got here: the system saw it, or somebody said it. Council 011 §1.5 replaced “Novaterra’s status is observed rather than reported” with something narrower and true: every step Novaterra performed carries its own evidence; everything else is something a person said, and the plan always says which. This enum is that sentence’s type. The third state is the ABSENCE of this value, never a third member. A plan element nobody has said anything about is null — and null means null counts, never zero, and never a flattering guess (ESTATE.md’s observed-window rule and LadderReport.wouldHaveCostUsd both already say this). Making silence a member of the enum is how it ends up folded into a denominator.
'witnessed' | 'asserted'
TaskStatus
'pending' | 'ready' | 'running' | 'blocked' | 'done' | 'failed' | 'cancelled'
TaskFailureKind
Whether a failed task’s own next attempt, unchanged, is guaranteed to fail the identical way (G3, design/THE-UNCHECKED-CALL-SITE.md §6 Layer 3 — “classify failures, and never retry a deterministic one”). 'deterministic': a schema validation, an unknown skill or capability, a floor violation, a template with nothing to render, or a refused consent card — the runner threw this BEFORE calling anything, from data it already had, so nothing about calling it again would differ. 'transient' is reserved for the symmetric case (a provider 5xx, a rate limit, disk, an unreachable peer) and is not written by anything yet; every retryable failure today is simply unclassified. null: not yet classified — including a skill’s own code throwing after its input parsed, which could be either and inventing an answer would be a guess wearing a fact’s clothes. resumePlan() (apps/api/src/modules/studio/resume.ts) reads this to keep Continue from silently replaying a step that cannot succeed.
'deterministic' | 'transient'
CallRepairReport
THE ONE REPAIR A STEP IS ALLOWED (G4, design/THE-UNCHECKED-CALL-SITE.md §6 Layer 4), recorded on the task’s own output so it can be read back long after the run — exactly as FanOutReport is. G3 above says a failure is deterministic: the same call will fail the same way for ever. §6 Layer 4 says that for ONE of those failures — arguments that do not fit the skill’s schema — the error is maximally informative: it names the skill, the field and what was expected, so a cheap model that can see the schema AND the error fixes it in one L2 pass. This is the record of that pass having happened. ⛔ A SINGLE OBJECT AND NOT A LIST, AND THAT IS THE BOUND ITSELF. §6 Layer 4’s first constraint is “once per step” — not a retry loop — and a shape that could hold two repairs is a shape in which two repairs are representable. There is deliberately nowhere to put a second one. ⛔ skill IS RECORDED AND NEVER CHOSEN. A repair rewrites the ARGUMENTS of the call the step already declared. It may not change which skill runs, because that is a different decision with a different consent posture — “repairing a shape is not repairing a decision, and an agent that blurs those two has built a bypass”. This field is here so a reader can check that it did not. Present only on a step that actually had one, which is almost none of them: a repair is refused outright for a world-acting skill, for a fanned-out step, and for any failure that is not the arguments failing to parse. See packages/agents/src/call-repair.ts for the whole list. ⚠️ WRITTEN IN TWO PLACES, AND THE TRACE IS THE COMPLETE ONE. Every repair, worked or not, is a TraceEvent whose data.repair is one of these. A step that then FINISHED also carries it on Task.output, which is what a screen reads without walking the trace — but a step that failed has no output to carry one, so ok: false is only ever found on the trace. Said here rather than discovered, because a reader who only knows about the output copy would conclude, wrongly, that every repair this node ever made succeeded.
| Field | Type | Notes |
|---|---|---|
skill | string | |
fields | string[] | optional · default [] |
because | string | |
costUsd | number | optional · default 0 |
ok | boolean |
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 |
rung | LadderFloor | null | optional · default null |
startedAt | ISODate | null | optional · default null |
finishedAt | ISODate | null | optional · default null |
timingEvidence | Evidence | null | optional · default null |
failureKind | TaskFailureKind | null | optional · default null |
LadderReport
Where a plan’s work actually landed, and what that cost. This is the project’s moat metric, and it is deliberately a DISTRIBUTION rather than a single number. An earlier council proposed p — the fraction of steps served from the library — which collapses four very different outcomes into one ratio: a step served by a template and a step served by a local model are both “covered”, and they differ by everything that matters. Two figures are published weekly: the L3 fraction, and the count of promotions. If the L3 fraction is not falling as the library grows, the thesis is wrong, and the project should learn that from a chart rather than from a bank statement. A read model. Computed from the tasks of a run; stored nowhere.
| Field | Type | Notes |
|---|---|---|
projectId | Id | |
counts | { L0: number (safeint, ≥ 0); L1: number (safeint, ≥ 0); L2: number (safeint, ≥ 0); L3: number (safeint, ≥ 0) } | |
costUsd | { L0: number; L1: number; L2: number; L3: number } | |
unknown | number (safeint, ≥ 0) | optional · default 0 |
totalUsd | number | |
wouldHaveCostUsd | number | null | optional · default null |
ProjectStatus
'draft' | 'planning' | 'running' | 'review' | 'done' | 'failed' | 'cancelled'
OutputRole
What a file in a finished project IS to the person who asked for it. ==================== WHY THIS EXISTS: THE 6-BYTE TEXT FILE ==================== assembleProject sorted a run’s outputs into three buckets — research last, code and data second, everything else first — and then ALPHABETICALLY. A gen_score run produced parts-asked.txt, score-notes.md, score.pdf, score.svg, score.json, score-pdf.json and research/briefing.md; the first four share the leading bucket, p sorts before s, and so the project opened on a six-byte file recording which parts had been asked for. The engraved score was fourth. The information to fix that already existed and was unused: a GeneratorStep declares what it produces, and assembleProject had never read a declaration. These four values are that declaration, carried to the surfaces that have to choose what a person sees first. ==================== THE VALUES ARE GRADED BY HOW DIRECT THE CLAIM IS ==================== Exactly the split ArchiveCoverSource makes (./archive.ts) and for exactly its reason: what a thing IS and what we INFERRED about it must not be the same word, or a client cannot label it honestly. - declared — a step’s own deliverable.file names this path. The author said “this file is what this step produces”, and the runner enforced it. There is no stronger claim available and nothing here is guessed. - produced — the step wrote this file and its OutputKind is the step’s own declared outputKind. Weaker than declared only in that the author named a KIND rather than a path, which is a thing generators do deliberately: gen_score’s compose step declares outputKind: 'pdf' and CANNOT name a file (the runner settles a named deliverable through a utf8 round trip that destroys a PDF, and the seed validator refuses a named .svg against outputKind: 'pdf'). A run’s app folder is produced too: nobody declared it by path, and it is plainly the thing that was made. NOT a guess from the extension and NOT the largest file — the kind is read off the step, and validateSeedGenerators() already enforces this same agreement in the other direction wherever both are present. - supporting — everything else the run left beside them: the reports a skill wrote for its own gates, working notes, the request echoed back to disk. - research — the run’s reading, under research/. Its own value rather than supporting because it already had its own bucket and losing that would be a regression. ⚠️ A project assembled with no plan in hand — a reused run, a row written before this landed — has every file supporting, and that is the honest answer rather than a defect. Nobody asked the generator, so nobody may report a declaration. The assembler’s original three-bucket order is kept as the tie-break INSIDE each value, so such a project sorts exactly as it always did.
'declared' | 'produced' | 'supporting' | 'research'
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 |
role | OutputRole | optional · default 'supporting' |
thumbnailUrl | string | null | optional · default null |
provenance | { producedBy: ‘ai’ | ‘human’ | ‘hybrid’; method: ‘label’ | ‘embedded’; markedAt: ISODate; disclosure: 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 |
reusedFrom | Id | null | optional · default null |
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' |
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.
| 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
| Field | Type | Notes |
|---|---|---|
id | Id | |
projectId | Id | |
taskId | Id | null | |
agentId | Id | null | |
kind | TraceKind | |
content | string | |
data | Json | optional |
at | ISODate |
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 [] |
stuck | 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 |
reusedFrom | Id | null | optional · default null |
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 |
reusedFrom | Id | null | optional · default null |
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 |
fresh | boolean | optional |
ReuseStats
The reuse layer’s deliverable: how much of the Studio’s work was served without touching a model. “90% of AI requests are recreations of something already generated” is only a claim until this number is real. Every field is derived from studio_reuse_events (one row per served run, keyed unique on the new project id, written only by the server on a genuine fingerprint hit) and the projects table — nothing a caller can POST moves it, which is why it can be trusted. Scoped to one being: served/total are that being’s generator-backed runs, so a shared node reports each person’s own reuse rather than a pooled figure.
| Field | Type | Notes |
|---|---|---|
windowDays | number (safeint, > 0) | |
since | ISODate | |
total | number (safeint, ≥ 0) | |
served | number (safeint, ≥ 0) | |
ran | number (safeint, ≥ 0) | |
ratio | number (≥ 0, ≤ 1) | |
savedUsd | number (≥ 0) | |
allTime | { total: number (safeint, ≥ 0); served: number (safeint, ≥ 0); ran: number (safeint, ≥ 0); ratio: number (≥ 0, ≤ 1); savedUsd: number (≥ 0) } |
SimilarPriorRun
The near miss: this being’s most similar prior run of a generator, when the inputs differ but not by much. The UI offers it as “start from this one” — it is NEVER auto-reused, because only an exact fingerprint match is free and safe to serve without asking.
| Field | Type | Notes |
|---|---|---|
projectId | Id | |
title | string | |
createdAt | ISODate | |
costUsd | number (≥ 0) | |
differingKeys | string[] |
ReviseRequest
Asking the team for a change on a project that already finished — “make the logo bluer”, “add a third section”, “the tone is too formal” — without starting over. The sibling of POST /api/studio/projects/:id/continue: continue picks up a run that STOPPED, this redoes a run that finished but is not what the person wanted. The default rule is deliberately one sentence long: a revision re-runs the TERMINAL steps (every task nothing else depends on — the ones that produced what the owner received) with the change folded into their instruction, and keeps every other step exactly as it delivered, handing it to the re-run steps as context. targetTaskId generalises the same rule to any step: that step’s own transitive dependents take the terminal steps’ place, so naming a terminal step and leaving it null agree. It goes through the same run() and the same metering as a first run, so every model call is billed and the budget guard still applies. A revised project’s reuse fingerprint is cleared: it is no longer the artefact its inputs describe (apps/api/src/modules/studio/reuse.ts).
| Field | Type | Notes |
|---|---|---|
request | string (min 1, max 4000) | |
targetTaskId | Id | null | optional · default null |
RevisionPlan
What a revision would re-run and keep, computed from the task rows and stored nowhere.
| Field | Type | Notes |
|---|---|---|
canRevise | boolean | |
reason | string | optional · default '' |
rerun | ResumeStep[] | optional · default [] |
keep | ResumeStep[] | optional · default [] |
held | ResumeStep[] | optional · default [] |
stuck | ResumeStep[] | optional · default [] |
nextSeq | number (safeint, > 0) |
RevisionStatus
'running' | 'done' | 'failed' | 'cancelled' | 'review'
ProjectRevision
One change asked for on a finished project, and what it produced. Held in project_revisions (additive raw SQL at boot, the studio_reuse_events pattern), one row per request, (projectId, seq) unique and seq contiguous from 1. Append-only in spirit: the log of what was asked and what each ask re-ran, changed and spent.
| Field | Type | Notes |
|---|---|---|
id | Id | |
projectId | Id | |
seq | number (safeint, > 0) | |
requestedBy | Id | |
request | string | |
status | RevisionStatus | |
rerunTaskIds | Id[] | optional · default [] |
changedPaths | string[] | optional · default [] |
costUsd | number | optional · default 0 |
createdAt | ISODate | |
finishedAt | ISODate | null | optional · default null |
AttemptSupersededBy
Why an attempt stopped being the current one.
'resume' | 'revision' | 'hand'
TaskAttempt
One attempt at a step, retained after the step was re-run. Appended at the moment of the reset, from the row about to be cleared — so the attempt carries exactly what the step actually had: when it started, when it ended, what it produced, what it cost, which rung served it, and why it failed. That last field is the one ISO 9001 clause 10.2’s effectiveness review turns on, and it is the one the reset used to null. (taskId, seq) is unique and seq runs 1..n, exactly as credit_entries(being_id, seq) does, so a hole or a duplicate in a step’s history is detectable rather than merely unlikely. NOT a full history of every state a step passed through. It is a record of superseded attempts: the live tasks row is always the current attempt, and seq + 1 is what it would be filed as.
| Field | Type | Notes |
|---|---|---|
id | Id | |
projectId | Id | |
taskId | Id | |
seq | number (safeint, > 0) | |
status | TaskStatus | |
startedAt | ISODate | null | optional · default null |
finishedAt | ISODate | null | optional · default null |
elapsedMs | number (safeint, ≥ 0) | null | optional · default null |
costUsd | number (≥ 0) | optional · default 0 |
rung | LadderFloor | null | optional · default null |
error | string | null | optional · default null |
output | Json | null | optional · default null |
evidence | Evidence | null | optional · default null |
supersededBy | AttemptSupersededBy | |
at | ISODate |
DueDateChange
A target date being set, moved or cleared. studio_due_dates keeps its shape and stays the fast current-value read; this is the authority behind it, on exactly the terms wallets.credits and credit_entries already have — “the ledger is what is TRUE; the wallet is what is FAST.” The cache still deletes its row when a date is cleared, so nothing downstream changes; the log keeps a row with dueAt: null, which is the distinction between “no date” and “a date somebody removed” that the old comment said did not matter. It matters: the difference between those two is the whole subject matter of change control, and it is why a baseline was impossible here. Every row is ASSERTED by construction — a person typed a date — so there is no evidence field.
| Field | Type | Notes |
|---|---|---|
id | Id | |
projectId | Id | |
taskId | Id | null | optional · default null |
seq | number (safeint, > 0) | |
dueAt | ISODate | null | optional · default null |
previousDueAt | ISODate | null | optional · default null |
reason | string | optional · default '' |
byBeingId | Id | null | optional · default null |
at | ISODate |
AssignmentChange
A step being handed to somebody, or taken off them. The same cache-and-log split as DueDateChange: studio_task_assignees still answers “who holds this now” in one indexed read, and this is the record of everyone who held it before. Overwriting that cell made ISO 9001 clause 7.2 actively worse than paper — the evidence of who did what was destroyed by the act of handing the work on.
| Field | Type | Notes |
|---|---|---|
id | Id | |
projectId | Id | |
taskId | Id | |
seq | number (safeint, > 0) | |
beingId | Id | null | optional · default null |
agentId | Id | null | optional · default null |
previousBeingId | Id | null | optional · default null |
byBeingId | Id | null | optional · default null |
at | ISODate |
TaskDuration
How long one step actually took. The two numbers that make this have been stored on every step since the runner existed and nothing had ever subtracted them: grep -rnE "finishedAt.*startedAt" apps packages returned nothing, and the only stored elapsed time in 83 tables was llm_calls.duration_ms, per model call, never rolled up to a step. currentMs is the live attempt. priorAttemptsMs is every superseded attempt added up, which is the rework — the thing that is invisible in every system that overwrites its retries, and the reason this shape exists at all rather than a bare elapsedMs on Task.
| Field | Type | Notes |
|---|---|---|
taskId | Id | |
title | string | |
status | TaskStatus | |
startedAt | ISODate | null | optional · default null |
finishedAt | ISODate | null | optional · default null |
currentMs | number (safeint, ≥ 0) | null | optional · default null |
priorAttemptsMs | number (safeint, ≥ 0) | null | optional · default null |
attempts | number (safeint, ≥ 0) | optional · default 0 |
totalMs | number (safeint, ≥ 0) | null | optional · default null |
evidence | Evidence | null | optional · default null |
ProjectDurations
A project’s elapsed time, and the honesty field that has to travel with it. THE RULE THIS SHAPE ENFORCES BY BEING SHAPED THIS WAY (Council 011 §1.5): no roll-up may report a number about work without reporting its witnessed fraction alongside it, in the same object and not behind a second call. A screen that wants to draw workingMs cannot fetch it without also holding witnessedFraction, and tests/contracts/ fails if the pair is separated. THE FOUR POPULATIONS, kept apart deliberately: witnessed the runner did the work and stamped the times asserted a person moved the step by hand and said when unattributed a real measured duration whose provenance predates Task.timingEvidence. Counted and reported, and excluded from the witnessed fraction on BOTH sides, because folding it in either direction is a guess about somebody’s work. silent no timestamp pair at all. Null, never zero, and in no denominator anywhere.
| Field | Type | Notes |
|---|---|---|
projectId | Id | |
tasks | TaskDuration[] | optional · default [] |
elapsedMs | number (safeint, ≥ 0) | null | optional · default null |
workingMs | number (safeint, ≥ 0) | null | optional · default null |
reworkMs | number (safeint, ≥ 0) | null | optional · default null |
measured | { witnessed: number (safeint, ≥ 0); asserted: number (safeint, ≥ 0); unattributed: number (safeint, ≥ 0) } | |
silent | number (safeint, ≥ 0) | |
witnessedFraction | number (≥ 0, ≤ 1) | null | optional · default null |
ProjectHistory
Everything this project’s plan remembers about itself, in one read.
| Field | Type | Notes |
|---|---|---|
projectId | Id | |
attempts | TaskAttempt[] | optional · default [] |
dueDates | DueDateChange[] | optional · default [] |
assignments | AssignmentChange[] | optional · default [] |
durations | ProjectDurations | |
generatedAt | ISODate |