Artifacts as data, and an authenticated route
Rendered output stops being text in a transcript and becomes a row: an artifact table, render_* tools, first-party cards, and one org-checked route. Plus the operational lesson from a default that served files without asking who you were.

Status: shipped. vocion-core #333 is running, including the fix described at the end of this post. Merged onto the release branch release/2026-09-15-rail (7db4f7f6) and deployed to a deployment we operate; migrations 0094–0099 are applied. The pull request is still landing on main individually behind that release branch.
An agent that assembles a table has two ways to hand it to you. It can write markdown pipes into the transcript, where the table is prose that happens to look aligned. Or it can produce a row whose content is structured, which a renderer draws — packages/core/src/libs/cards/firstParty/dataTable.tsx builds a sortable table on tanstack, and the canvas in packages/core/src/features/dashboard/canvas/ is where a person moves and resizes the tiles.
The second one requires a table and a route. Here they are.
The row
CREATE TABLE IF NOT EXISTS "artifact" (
"id" serial PRIMARY KEY NOT NULL,
"org_id" text NOT NULL,
"project_id" text REFERENCES "project"("id") ON DELETE CASCADE,
"conversation_id" integer REFERENCES "conversation"("id") ON DELETE SET NULL,
"message_id" integer,
"canvas_id" integer REFERENCES "canvas"("id") ON DELETE SET NULL,
"kind" text NOT NULL,
"title" text NOT NULL,
"spec" jsonb DEFAULT '{}'::jsonb NOT NULL,
"url" text,
"tile" jsonb,
"pinned" boolean DEFAULT true NOT NULL,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
kind is text, not an enum, on purpose: a new card kind should be a code change, not a migration. The kinds that exist live in packages/core/src/libs/cards/specs.ts as ['table', 'markdown', 'chart', 'record', 'link', 'file'].
spec holds the content; url is populated only for the file kind, and tile is the canvas placement ({ slot, span }). That split is what makes the rest of the design work: a card artifact has no bytes anywhere, and the route below returns its spec as JSON rather than serving a file. The kinds and their zod shapes are in packages/core/src/libs/cards/specs.ts.
The tools
Four tools live in packages/core/src/services/agents/tools/renderArtifacts.ts: render_table, render_markdown, render_chart, render_record. That file's header states why they are registered for every agent rather than granted per role — none of them has a side effect outside the conversation.
{
name: 'render_table',
description: 'Render rows and columns as a SORTABLE TABLE card in the answer and on the canvas. '
+ 'Use for any list you assembled instead of a markdown table. '
+ 'Give every column a `type` so numbers align and dates format.',
schema: z.object({
title: z.string(),
columns: z.union([z.array(columnSpec), z.string()]),
rows: z.union([z.array(z.record(z.string(), cellValue)), z.string()]),
...tileArgs,
}),
}
Both array arguments accept a string as well, parsed by a coerceJson helper in the same file, because models stringify nested arguments often enough that refusing them is a worse outcome than parsing them defensively.
The interesting part is the return value. The tool does not echo the payload back to the model:
return `Rendered ${kind} "${row.title}"${summary ? ` (${summary})` : ''}${where}. `
+ `The card carries the content — do NOT repeat it as text; refer to it by title.`;
A one-line receipt, and the instruction in it is the point: a model handed its own payload back will restate it, and you get the table twice.
Rejections come back the same way. persistAndEmit catches the schema error and returns render_<kind> rejected: <message>. Fix the payload and call again. as tool output, so a bad payload is a correctable turn rather than a thrown tool.
Each tile is an artifact row. The canvas stores a layout — { artifactId, slot, span } — not a copy of the content.
One schema, several readers
A card declares itself once and names the surfaces it is allowed to appear on:
export const chartCard = defineCard({
slug: CHART_SLUG,
name: 'Chart',
surfaces: ['chat', 'canvas', 'workflow-run', 'review-queue', 'activity-feed'],
dataSchema: chartSpecSchema,
Renderer: ({ data, surface }) => <ChartCardView data={data} surface={surface} />,
});
Density is a function of surface, not a separate component. dataTable.tsx declares const CHAT_ROW_LIMIT = 8, so the table shows eight rows in a conversation and all of them on a canvas; markdown.tsx does const dense = surface !== 'canvas'. One renderer, one schema, one place a bug can be.
The route, and the mistake behind it
GET /api/artifacts/:id serves an artifact. The authorization is four lines, and the shape of them is the point:
// packages/core/src/libs/tools/artifacts/serve.ts
const row = await opts.lookupRow(Number(opts.id));
if (!row || row.orgId !== opts.callerOrgId) {
return { status: 404 }; // an org mismatch is a 404, not a 403
}
if (row.kind !== 'file') {
// Card artifacts have no file: the spec IS the content.
return { status: 200, json: row.payload ?? { kind: row.kind, title: row.title, spec: row.spec } };
}
A cross-tenant hit and a miss are indistinguishable, so the response leaks nothing about what exists. File-backed artifacts go on to a path check in the same file:
const abs = path.resolve(dir, filename);
if (!abs.startsWith(path.resolve(dir) + path.sep)) { return null; }
The cases are named in the tests rather than implied — packages/core/src/app/api/artifacts/[id]/route.test.ts has 'is a 404 for another org — by session and by token', 'is a 401 with no credentials and a 404 for a mismatched filename or a traversal attempt', and 'returns a card artifact as JSON (no file behind it) and 404s it for another org'.
That route is new, and it is new because of a mistake worth naming.
Artifacts that do have bytes used to be written to a default directory of public/artifacts, with a default URL base of /artifacts — the pre-fix artifactsDir() and artifactsUrlBase() in packages/core/src/libs/tools/artifacts/store.ts, whose comment read "served by Next at /artifacts/<file> — good for self-host/dev". That is a reasonable default for a laptop. It is a bad default in production, because Next serves everything under public/ to anyone with the URL — so the only thing standing between a document and the internet was whether you could guess its filename. Filenames were content-addressed, so not trivially guessable, which is secrecy of a URL. Secrecy of a URL is not authorization.
On a deployment we operate, that default was in force. It was found and closed the same day, in commit f4a93e42 (fix(artifacts): serve files through an authenticated, org-checked route — never from public/), fifteen files — and shipped in the same release as the feature it belongs to.
The class of mistake generalises past this codebase: authorization that lives in the web server's static-file layer is authorization your application cannot see, cannot test, and cannot reason about. A default that is convenient in development and unsafe in production will eventually be running in production, because defaults are what you get when nobody made a decision.
The fix has four parts, and only the first one is the obvious one.
export function artifactsDir(): string {
return process.env.VOCION_ARTIFACTS_DIR ?? path.join(process.cwd(), '.artifacts');
}
/** True when the configured directory would be served statically — never acceptable in production. */
export function artifactsDirIsPublic(): boolean {
const dir = path.resolve(artifactsDir());
return dir.startsWith(path.resolve(process.cwd(), 'public') + path.sep);
}
Second, a loud warning at write time if anyone reintroduces the layout in production, inside saveArtifact:
if (process.env.NODE_ENV === 'production' && artifactsDirIsPublic()) {
console.warn('[artifacts] VOCION_ARTIFACTS_DIR points under public/ — files there are '
+ 'served unauthenticated. Move it to a private volume.');
}
Third, artifactsUrlBase() now defaults to /api/artifacts, so the URL saveArtifact writes into a new row points at the authenticated route rather than the static path. Fourth — the part that makes a fix like this shippable rather than link-breaking — old URLs already written into rows and prose are rewritten on read:
export const API_ARTIFACTS_BASE = '/api/artifacts';
/** Rewrite a legacy `/artifacts/<filename>` URL to the authenticated route. */
export function artifactHref(url: string | null | undefined): string {
if (!url) { return '#'; }
const m = url.match(/^\/artifacts\/([\w.-]+)$/);
if (!m) { return url; }
// …
}
artifactHref is applied at the call sites that render a stored URL — the mission-run detail page and the file card — and the mission harvester's regex was widened to match both shapes.
The escape hatch survived, narrowed. VOCION_ARTIFACTS_URL_BASE still exists for deployments that front the directory with their own authenticated storage. What changed is that its contract is now written down instead of assumed — packages/core/.env.example replaced a two-line comment with a warning ("NEVER point this under public/ in production — Next serves public/ to anyone with the URL"), and docs/canvas.md gained a section headed "Serving files — never from public/".
Where this lives in the repo
Read off release/2026-09-15-rail at 7db4f7f6, the commit that shipped. #333 is merging to main individually behind it.
- Schema:
packages/core/migrations/0095_artifact.sql,packages/core/src/models/Schema.ts - Tools:
packages/core/src/services/agents/tools/renderArtifacts.ts - Cards:
packages/core/src/libs/cards/specs.ts,packages/core/src/libs/cards/firstParty/(dataTable.tsx,markdown.tsx,chart.tsx,record.tsx,link.tsx),packages/core/src/libs/cards/resolve.ts,registry.ts - Canvas UI:
packages/core/src/features/dashboard/canvas/—CanvasView.tsx,CanvasGrid.tsx,ArtifactTile.tsx,EmptyTile.tsx, and the purecanvasReducer.ts(+.test.ts) - Service and router:
packages/core/src/services/ArtifactService.ts,packages/core/src/routers/Artifacts.ts - Route and authorization:
packages/core/src/app/api/artifacts/[id]/,packages/core/src/libs/tools/artifacts/serve.ts - Storage defaults and the legacy-URL rewrite:
packages/core/src/libs/tools/artifacts/store.ts,url.ts, andpackages/core/.env.example - Tests naming the cross-tenant cases:
packages/core/src/app/api/artifacts/[id]/route.test.ts - Docs:
docs/canvas.md, including its "Serving files — never from public/" section
Related: A canvas beside the conversation, the user-facing half, and One card, every surface.