Skip to content

Outputs

Assembly

Once every task in the graph has settled, assembleOutputs() (packages/agents/src/outputs.ts) walks workspace/projects/<projectId>/ (up to 400 files) and turns whatever is actually on disk into ProjectOutput rows — it describes the real filesystem, not a list the agents claimed to produce:

{
id, kind: OutputKind, name, path, // relative to the project root
url: string, // /api/files/project/<projectId>/<path>
size: number,
previewUrl: string | null,
}

File extension maps to a kind (.mdmarkdown, .docxdocx, .png/.jpg/.svgimage, .htmlhtml, .json/.csv/.yamldata, common code extensions → code, everything else → text). App detection is special: any package.json that depends on react (optionally fastify/express/hono/koa, or a pnpm workspace) is reported once, as its whole folder, kind react-app or fullstack-app — not as dozens of separate file rows — with a previewUrl pointing at its build output (dist/index.html, build/index.html, etc.) if one exists. Outputs are sorted deliverables-first: research notes (research/*) and code/data files sink below the actual document, image, or app.

The output leads

The project page puts the deliverable first — above the plan, above the trace. A person who briefed a thing wants the thing; the reasoning is available, not in the way.

The stage picks what leads: something previewable if there is one, then the shallowest app folder, then the file the last finished task wrote, then whatever the assembler put first. It then renders in one of three modes.

A built app runs. It loads in an iframe with sandbox="allow-scripts allow-forms allow-popups" and no allow-same-origin — generated code is untrusted code, and a preview that could read your session cookie would be a hole dressed as a convenience.

A file renders through the viewer registry — markdown, text, JSON, tables, images, PDF, media, HTML — the same registry the Files widget uses, so a type that gains a viewer gains it everywhere.

An app that never built explains itself. This is the case that used to be an empty rectangle. Instead of a blank frame, the stage says “This app was never built, so there is nothing to run here”, and shows:

  • how many files the scaffold actually wrote;
  • how to run it yourself — a copyable cd workspace/projects/<id> plus the scaffold’s own reported run steps (falling back to npm install / npm run dev);
  • the source, and a zip of the whole project;
  • a <details> block, “What the build printed”, carrying the real build log.

All of that is scraped from the run’s own trace — the last tool.result from the scaffold skill — so it is what the build actually said, not a reconstruction. Where no log was recorded it says “No build log was recorded for this run” rather than inventing a reason.

Previewing

GET /api/files/project/:projectId/*

serves any file inside a project directory with the right content type, and resolves a built app’s index.html with SPA-style fallback — a client route like /about with no matching file on disk walks up to the nearest index.html (or the standard dist/build/out folders at the project root) so a React Router app previews correctly inside an iframe, not just its literal entry point. HTML previews get a permissive CSP header (unsafe-inline, unsafe-eval, frame-ancestors 'self') because they’re framed same-origin by the desktop’s preview surface, not loaded standalone. Markdown, images, and scene3d/html outputs render inline in the Outputs panel (OutputsPanel.tsx) using this same route; everything else offers a download.

Downloading

GET /api/files/download?id=<fileId> → the file, or a zip if it's a directory
GET /api/files/project/:projectId.zip → the whole project as novaterra-<title-slug>.zip

Both stream a zip on the fly with archiver rather than building one on disk first — the same mechanism the files connection uses for a whole indexed folder, since project outputs and indexed files are served by the same general files API.

QA: the reviewer pass

Before a project reaches done, one strong-tier structured call (reviewProject() in packages/agents/src/qa.ts) reads a digest of every text output (up to 24,000 characters total, 6,000 per file) against the original brief and judges it:

{
verdict: 'pass' | 'pass-with-notes' | 'needs-work',
summary: string, // under 120 words
strengths: string[],
issues: string[],
ambiguity: { question: string, options: string[] } | null,
}

The prompt is explicit that ambiguity is only for a real choice the brief itself left open — never a taste call the reviewer happens to disagree with. When it is filled in, a decision-kind Signal lands on your desktop with the reviewer’s own options as one-tap answers, source: "studio:<projectId>". QA runs automatically whenever an OpenRouter key is configured; without one, a project still reaches done on the strength of its task outputs alone, just without the extra review pass.

The reviewer’s verdict is a reading, not a gate. reviewProject()’s verdict is logged to the trace and shown to you; on its own it does not change the project’s status. That is deliberate — a model’s opinion of a document is not the same class of evidence as a fact about a file — and it is also why the deliverable contract below exists.

Deliverable contracts: what a step promised will exist

A model can describe a finished thing it did not finish. So a generator step may declare a contract, and the run checks it against what the skills themselves reported, never against the model’s account of its own work.

ContractChecksSource of truth
DeliverableContractword counts, headings, frontmatter keys, source linksthe file on disk
BuildArtefactwhether the app compiled — that a named entry point (default apps/web/dist/index.html, matched as a path suffix) actually existsthe build skill’s own built, previewPath and buildLog. Nothing is re-derived: a stat() would tell you the file is absent, which the empty preview already told you. Only the skill that shelled out to npm knows why
AuthoredArtefactthat an agent, generator or reviewable skill bundle a step promised to create really existsthe authoring skill’s own reported result

A leaf step whose contract failed makes the run failed with its outputs still attached, and a ProjectReview { verdict: 'needs-work', source: 'contract' } rides beside the status carrying one issue per failure — including the tail of the build log, because “your app did not compile” is only actionable next to the compiler saying why.

This came out of a specific, memorable failure. The Studio generated a Snake game and reported Ready · 6/6 tasks with a QA note praising the architecture at length. The trace, meanwhile, had said three separate times: code.scaffold_react_node: Web build did not complete; source is still available. The app had never compiled. The reviewer had read the source and approved it; nothing had checked that anything ran. Opening the outputs found empty previews.

Two design notes worth knowing:

  • Build evidence is collected run-scoped, not per step. In both scaffolding generators the step that runs the build is never the last one, and only a leaf shortfall can move a run’s status — so a verdict kept beside the step that produced it could never have failed a run. That is the exact shape of the hole the Snake game fell through.
  • maxRepairs: 0 on a build contract is the argument, not an oversight. The build runs inside the scaffold skill, on its own template, before the model has written a line of the app. A failure there failed for a reason no rewritten prompt can touch — npm could not be spawned, the registry was unreachable, the time budget ran out — and a repair buys a second full install plus a second model round-trip to reproduce the identical failure, on the same key your live app spends from. The honest failure, carrying the compiler’s own words, is worth more.

When a project fails

A project is only marked done if it produced at least one real output and at least one leaf task (a task nothing else depends on — i.e. an actual deliverable, not just a research step) succeeded, and every declared deliverable contract on a leaf was met. Anything short of that is marked failed, with the trace stating exactly how many of how many tasks didn’t finish, rather than silently reporting done with nothing to show for it — and the files you did get stay downloadable either way.