One report row. A user files it. So does the agent.
Two feature requests describe the same row from opposite ends. A human wants to report a problem and quote a reference; an agent that hit a defect mid-run wants to file it and not file the same one twice. Rather than two attribution systems that drift, there is one record, one public projection, and one trust boundary: everything a caller could forge — who reported it, whether it is readable, what run it belongs to — is derived from an identity the request body cannot reach. Wire nothing and your swarm is byte-identical: the form, the routes, and the capture plane are all opt-in.
Authenticated-only. Intake is authenticated: an unauthenticated submit is a 401, and every report carries a real principal (a signed-in user, an agent, or an operator). The source and visibility fields are DERIVED from that identity — the request body cannot reach them — and a human's report is readable back only by its author.
When to reach for this — and when not
This plane is for reports about the software itself: a signed-in user who says a button does nothing, or an agent that keeps hitting a tool that fails the same way every run. What you get for wiring it is one durable row with server-derived attribution, a grouping fingerprint that counts occurrences, and an owner-scoped read-back — the same record whether a human or an agent filed it. It is deliberately narrow, so it is worth naming which parts to wire, and where something else fits.
| Reach for | When | What you wire |
|---|---|---|
| <IssueIntake> + issueIntakeRoutes() | a signed-in human should report a defect and quote a reference back | the form, the three routes, a sink with the store facet, a shared rate-limit store |
| bugCapture + report_issue/check_issue | an agent should file the defects it observes mid-run, deduped against what is already open | createBugCapture threaded into defineSwarm, over the SAME sink |
| issueTriageRoutes() | operators acknowledge, resolve, mark duplicate, and reply | the triage facet (all five ops) + the studio issues plugin |
| issueExportRoute() | you must hand an auditor a scrubbed archive of a filtered set | the audit facet + a resolveAttachment resolver for the bytes |
| a plain form or external ticketing | you want product feedback or a feature request, not a deduped defect row | nothing here — the fingerprint, the lifecycle and the per-run cap are cost you would not use |
Where it is the wrong tool: there is no anonymous path — an unauthenticated submit is a 401, so a public "contact us" box is a different surface. It is not error monitoring: a human or an agent authors each row on purpose, and the rate limit plus the per-run cap exist precisely because this is not built to swallow every exception at volume — point an APM at that. And it is not a decision channel: when a run needs a person to choose or approve, that is a HITL ask, not a bug report.
The form a user touches
<IssueIntake> is an end-user surface, not a studio section — the studio is the operator console, and a support form must never be a tab inside the shell that also holds the agent editor. It submits a report, then reads the caller's own reports back with the operator's visible replies threaded under each one.
"use client";
import { IssueIntake } from "@nightowlsdev/react";
// Mount it anywhere a user already is: a settings page, a help modal, a footer link. It assumes NO
// <SwarmProvider>, no chat stylesheet, no studio shell — it themes off the --owl-* custom properties
// when they exist and renders correctly when they do not.
export function SupportPanel({ token }: { token: string }) {
return (
<IssueIntake
base="/api/swarm/issues"
// Best-effort: the routes are authorized server-side regardless, so a mint hiccup falls back to cookie auth.
getToken={() => token}
// ⚠ REQUIRED, host-authored, rendered VERBATIM beside the submit button. Say who reads these reports,
// how long they are kept, and whether they leave this deployment. A BLANK string WITHHOLDS the form —
// the framework cannot write this sentence for you, so it refuses to collect free text without it.
collectionNotice="Reports are read by our team, kept for 90 days, and never leave this workspace."
onFiled={(reference) => console.log("filed as", reference)}
/>
);
}The collection notice is required, and the form refuses to render without it. collectionNotice is a host-authored string with no default. A blank one withholds the submit form (the "your reports" list still renders — reading your own rows collects nothing). The framework cannot know what this deployment does with a free-text report — who reads it, how long it is kept, whether it leaves the tenant — so it cannot write that sentence, and a form that collects free text while promising nothing is exactly the failure this requirement exists to prevent.
It never truncates the description to the cap. The server rejects an over-cap draft with a sentence that says by how much; the counter turns into a warning and the button stays live. Silently trimming in the browser would file a report missing the half that said what happened.
What a report is
The submitter-facing shape (PublicReport) is built by one function, toPublicReport, and nothing else. It is not an Omit of the internal row: the wire shape genuinely differs, and every field a submitter can see is derived from an actor union they never see.
| Field | What it is | Set by |
|---|---|---|
| reference | the quotable handle, NO-XXXXXXXXXX. Derived from a UUID so it is not enumerable, uppercased so it survives a phone call | server-minted |
| source | user · agent · operator — who reported it | derived from the actor, never a body field |
| submitter | { userId?, displayName? } — a human's own id, or an agent's slug as the display name | derived from the actor |
| visibility | submitter vs internal. Only a human's own report is readable back; an agent-filed row is internal even when it names onBehalfOfUserId | derived: user ⇒ submitter |
| status | the wire vocabulary: new · acknowledged · investigating · resolved · wont-fix · duplicate-of. A draft has no public rendering and never crosses | mapped from the internal lifecycle |
| duplicateOf | when set, overrides the wire status to duplicate-of whatever the stored status says — the only honest answer to a submitter | triage-set |
| occurrences | how many times this same failure has been recorded, from the grouping fingerprint | store-maintained, atomically |
Several internal fields must never reach a submitter — the fingerprint (invertible, so a probe oracle), the confidence (reads as an accusation), the trusted origin (run and thread ids that identify other people's conversations). The repo has no field-visibility mechanism, so the control is a closed key set plus a per-route runtime belt, assertPublicReportShape, that every route walks over anything it is about to serialize.
The routes, and the trust boundary they own
Three route files, one accessor. POST /issues files for the authenticated caller and nobody else, GET /issues/mine is owner-scoped, and GET /issues/[ref] serves the owner a public projection and an operator holding the issues admin scope the internal row.
// app/api/swarm/issues/route.ts — the submit handler. `mine` and `[ref]/detail` are the same shape.
import { runner } from "@/lib/runner";
export const { POST } = runner.issueIntakeRoutes().submit;
// app/api/swarm/issues/mine/route.ts -> export const { GET } = runner.issueIntakeRoutes().mine;
// app/api/swarm/issues/[ref]/route.ts -> export const { GET } = runner.issueIntakeRoutes().detail;The plane is host-assembled — the report table is host-owned, so there is no shipped storage adapter to reach for. Two fields carry the whole safety story: sink (an IssueSink) and a rateLimitStore with no default.
// lib/runner.ts — the issue plane is HOST-assembled, like skillPlane/connectionsPlane. The report row is
// host-owned (there is no shipped storage adapter for it), so you bring the sink.
import { createInMemoryIssueSink, createInMemoryRateLimitStore } from "@nightowlsdev/core";
import { createNextjsRunner } from "@nightowlsdev/runner-nextjs";
export const runner = createNextjsRunner({
// ...engine, auth, and the other planes you already pass...
issuePlane: {
// Wire your OWN IssueSink in production; createInMemoryIssueSink() implements every facet and is the
// shape a conformance-tested adapter (verifyIssueSink) mirrors.
sink: createInMemoryIssueSink(),
// ⚠ REQUIRED, no default. On a serverless deployment an in-memory Map is one window PER WARM LAMBDA, so
// the cap you configured is not the cap you get. Pass a Redis/Postgres-backed store; pass the in-memory
// one EXPLICITLY only if this deployment really is one process.
rateLimitStore: createInMemoryRateLimitStore(),
// Stamped onto every intake submission as its first-seen release. null => this host does not track releases.
release: process.env.RELEASE ?? null,
},
});Nothing on the row is read from the body. The only fields the route reads out of a submit are title, description, severity and attachments. reporter is hard-coded to { kind: "user", userId: authCtx.userId }, so source, visibility and status all derive from an identity the body cannot reach; release, origin (always null — a route submission has no run) and confidence are server-stamped. A body carrying source: "operator" or status: "resolved" changes nothing.
Every miss on the detail route returns the same 404 — an unknown reference, someone else's, a draft, a frozen row, all one body. A route that said "that reference is not yours" would confirm the reference exists, and references are the handle a submitter quotes over the phone; an oracle over them enumerates the tenant's whole issue list one guess at a time. All three routes are rate-limited per (tenantId, userId), and the whole family throws at construction when the sink lacks a store or the shared limiter is absent.
The other end: an agent files its own bugs
The same row is written automatically. Wire a bugCapture plane and defineSwarm registers two agent tools — report_issue and check_issue — and a per-run reducer that turns the event stream into reports without ever stalling it.
// lib/swarm.ts — the automatic capture plane. Thread ONE handle everywhere it is needed.
import { createBugCapture, createInMemoryIssueSink, defineSwarm } from "@nightowlsdev/core";
import { agents, cost, modelFactory, models, storage } from "@/lib/swarm-config";
// Call createBugCapture ONCE and pass the SAME handle to defineSwarm, any background runner and the reaper.
// Two planes in one deployment split every report across unrelated sinks — dedupe never joins them.
export const bugCapture = createBugCapture({
sink: createInMemoryIssueSink(),
// Optional pepper (D8-8): default OFF. Enabling it FORFEITS cross-deployment grouping. Validated eagerly here.
// pepper: process.env.BUG_CAPTURE_PEPPER,
});
export const { engine } = defineSwarm({
storage, agents, models, modelFactory, cost,
// Registers the two agent tools (report_issue / check_issue) AND the per-run capture reducer.
bugCapture,
});A model supplies a title and a description and nothing else: the tenant, the user, the run, the agent slug and the whole origin bag are read from the run context and stamped server-side. An agent's report is filed as a draft (confidence: "likely"), because an agent reporting a bug is making an inference through a channel it controls the text of; the framework auto-files only what it observed itself with a complete evidence set. A per-run cap — enforced in the store from trusted { tenantId, runId } metadata, not from anything the model can invent — stops an agent in a hot failure loop from out-spamming every human on the intake surface.
Grouping, and redaction on the way in
"Have we seen this before" is answered by a fingerprint: bugFingerprint scrubs the error, normalizes away the parts that vary between two occurrences of the same defect (ids, timestamps, numbers, URL paths), and hashes the result. It returns both halves from one call, so the string that entered the digest and the normalized string persisted beside it can never diverge.
import { bugFingerprint } from "@nightowlsdev/core";
// Scrub -> normalize -> hash, returning BOTH halves from ONE call so the string that entered the digest and
// the string you persist as `normalized` can never diverge (D8 condition 4).
const { normalized, fingerprint } = await bugFingerprint({
message: "ETIMEDOUT connecting to https://api.example.com/v3/users/8f3c-... after 30000ms",
toolName: "http_get",
code: "network_timeout",
agentSlug: "researcher",
});
// normalized: "ETIMEDOUT connecting to <url:api.example.com> after <n>ms" — ids, timestamps, numbers collapsed
// fingerprint: "v1:<64 hex>" (or "v1k:<64 hex>" with a pepper). INTERNAL-ONLY — the tuple space is invertible,
// so a digest handed to a submitter is a confirmation oracle against every other report in the tenant.The order is the security property: the digest is taken over the scrubbed string, so a bearer token in an error message never reaches the hash input. The digest is a grouping handle and nothing else — not a security token, not a retry key — and it is internal-only, because the tuple space is small and genuinely invertible.
Free text is redacted on the way in. FR-050's redaction runs over machine-assembled snapshots; a human's free-text field needs it oningress. assembleReport scrubs the title and description through the redaction policy and records the policy's version on the row, so a later fix to the pattern set can identify which stored strings came from the weaker pass — an exported archive is a copy nothing can revoke. An operator's reply is scrubbed too: one policy, both directions.
API reference
- <IssueIntake>, createIssuesClient, and the IssueSubmitOutcome union (filed / rejected / rateLimited / failed — the first three are answers, not errors), all from @nightowlsdev/react.
- runner.issueIntakeRoutes() returns { submit: { POST }, mine: { GET }, detail: { GET } }; runner.issueTriageRoutes() is the admin half (the five mutations behind adminRouteGate("issues")), and runner.issueExportRoute() the audited archive export. Each throws at construction when its required facet is missing.
- toPublicReport / toPublicReportList / assertPublicReportShape: the one projection, the list form (drafts dropped), and the per-route runtime belt.
- assembleReport, ReportActor, InternalReport, PublicReport, ReportDraft — the record, its actor model, and the server-owned assembler, all from @nightowlsdev/core.
- createBugCapture, bugFingerprint, IssueSink (facets: store and health required; triage / delivery / audit optional and absence-refusing), createInMemoryIssueSink, and verifyIssueSink for a host adapter.
- reportIssue / checkIssue and ISSUE_TOOL_NAMES: the engine-neutral half of the two agent tools the native engines register from.
Authenticated-only. Every intake route authenticates: submit and mine are the signed-in tier, detail is owner-or-admin, and triage is the issues-scoped admin tier. There is no anonymous or unauthenticated path — an unauthenticated request is a 401, and every report is attributed to a real principal whose identity the request body cannot forge.
Common pitfalls
- A defaulted rate-limit store on serverless. createInMemoryRateLimitStore() is one window PER WARM LAMBDA, so a "20 per hour" cap silently becomes unlimited. The family throws at construction until you pass a store — wire a Redis/Postgres-backed one, and pass the in-memory one only when the deployment really is a single process.
- Two capture planes in one deployment. Calling createBugCapture twice splits every report across unrelated sinks — an engine-side failure and the same run's reaped stall never dedupe. Call it ONCE and thread the same handle to defineSwarm, any background runner and the reaper; assertSameBugCapture exists to catch the drift by object identity.
- Enabling a pepper without meaning to. It is OFF by default, and turning it on forfeits cross-deployment grouping and opens a dual-read backfill window (v1: rows beside v1k: rows). Enable it deliberately or leave it unset.
- Handing a fingerprint to a submitter. The digest is internal-only: the tuple space is invertible, so a digest given to one submitter is a confirmation oracle against every other report in the tenant. check_issue returns it to the agent/operator tiers and never to a submitter, and the projection belt refuses to serialize it onto a public response at all.
- Expecting an agent-filed row to be the user's. Only a human's OWN user report is readable back. An agent report is internal even when it carries onBehalfOfUserId — the agent observed the defect; the human did not author it — so it never appears in that user's mine list.
- A half-built triage facet. issueTriageRoutes() checks all five ops (and listTriage) at construction and refuses to mount if any is missing, rather than 500 in front of an operator on the one button nobody finished. Implement the whole facet or do not mount the family.
Where to go next
Attachments
A report can carry a screenshot. Refs cross the wire, never bytes — and the intake route freezes who owns each one at the moment it is accepted.
Approval modes
Why report_issue sits at danger 2 and needs approval — and what that approval does and does not ratify.
@nightowlsdev/core
The record, the actor model, the projection belt, and the capture plane the native engines register their tools from.
Building on Night Owls? See the source on GitHub.