Stacked on #4418. Merge that first. The UI slice of the dashboard agent: the side panel, the chat transport wiring, message and card rendering, suggested prompts, and chat history. #4418 works without this — the system is simply invisible. The diff is mostly components, so the notes below cover only the three decisions you can't read off the markup. Behavior and a hands-on walkthrough live in GUIDEBOOK.md, which lands with #4525. ## Decisions worth knowing - **Action rows always render at the end of a turn.** The model's emission order isn't trusted for layout, so action blocks are split out of the stream and appended last. Display only — `answered` stays keyed on the emission index. - **The last-chat memory is org-true.** It's keyed by the chat's own organization, and a foreign or deleted chat comes back as a 404 the client treats as gone, rather than an empty chat it keeps around. - **A dead stream self-heals from the settled transcript.** Terminal records are written to the chat row after the client's stream closes, so the panel re-reads it. The poll gate is any unfinished turn — a dangling tool part, not just an open investigation. ## Notes - Gated by `canAccessDashboardAgent`; no behavior change with the flag off. - Page marks: `handle.agentPageContext` on 47 routes, ~20 lines each. - Entry points: Ask Trigger button, ⌘J, Help & Feedback. The old ⌘I and `?aiHelp=` links keep working. ## Screenshots <img width="1440" height="788" alt="Screenshot 2026-08-07 at 15 14 29" src="https://github.com/user-attachments/assets/f4e89e8d-13ed-4be3-a88d-d5cca3ece0fa" />
@internal/dashboard-agent
The in-dashboard agent, built on chat.agent and deployed as its own Trigger
project. This is the launch-week dogfood: we run our own product on the
primitive we ship.
Why a separate package (not inside apps/webapp)
The agent has no access to the main database, ClickHouse, or webapp internals — it reads everything via the API. Living in a standalone package that doesn't depend on the webapp makes that firewall structural: the package physically cannot import webapp server code. It also keeps the webapp a pure Remix app instead of a dual Remix-app-and-Trigger-project, and gives the agent a small, fast, independently deployable + testable build context.
It writes conversation state to its own datastore via @internal/dashboard-agent-db
(the same package the webapp reads from for the History tab). It never touches
Prisma.
Deploy / dev
This is a Trigger project with its own trigger.config.ts. The project ref is
read from TRIGGER_DASHBOARD_AGENT_PROJECT_REF (never hardcoded — public repo).
cd internal-packages/dashboard-agent
TRIGGER_DASHBOARD_AGENT_PROJECT_REF=<your-project> pnpm run dev # trigger dev
TRIGGER_DASHBOARD_AGENT_PROJECT_REF=<your-project> pnpm run deploy # trigger deploy
Runtime env the deployed task needs: DASHBOARD_AGENT_DATABASE_URL (the agent
datastore) and OBJECT_STORE_* (chat.agent's built-in conversation snapshot).
Consumed by the webapp
The webapp imports only the task type for transport type-safety:
import type { dashboardAgent } from "@internal/dashboard-agent";
Never a value import (see src/index.ts).
What a call costs
Two numbers decide the bill: the cacheable prefix every call pays for, and the conversation that rides on top of it.
- The prefix (system prompt + tool schemas) is measured in
src/prompt-prefix.tsand budgeted insrc/prompt-prefix.test.ts: explicit ceilings per mode, plus a committed snapshot of prompt chars/tokens, tool-schema chars/tokens, tool count and the fingerprints. A change that grows the prefix past a ceiling must move that ceiling in the same PR and accept the snapshot diff (vitest -u) — that is the whole point of the numbers. - The conversation is compacted in
src/compaction.ts: above 60k tokens of conversation (on top of the ~21k prefix) the older part becomes a Haiku-written summary. The UI transcript is never compacted, and an open investigation is pinned back onto the model's history verbatim, so a summary can never cost the agent theinvestigationIdit has to keep revising.
Turn evals
A sampled fraction of turns is scored by an LLM judge (dashboard-agent-eval-turn), which
writes one chat_turn_evals row. The rules live in one file, src/eval-policy.ts:
- Sampling.
DASHBOARD_AGENT_EVAL_SAMPLE_RATE, default 0.1 — the judge is a full model call per turn and nothing reads the rows yet. Golden / CI runs are a separate lane:DASHBOARD_AGENT_EVAL_SAMPLE_RATE_CI(default 1) applies only whenDASHBOARD_AGENT_EVAL_CONTEXT=ci, so neither lane can change the other's rate. - Redaction. Run payloads and outputs, query result rows, file contents and span attributes are replaced by their shape before the turn leaves the agent. The row keeps the judge's derived verdict only — never the question, the answer, or any tool data.
- Code mode. A turn that called a source tool is not judged at all.
- Opt-out. Per-org, via the
dashboardAgentTurnEvalsEnabledfeature flag. The agent asks the API before every judged turn and judges only on an explicit yes. - Retention. Rows are dropped after 30 days by the webapp's dashboard-agent sweep.
When a document and the code disagree about any of the above, the code is the fact and the document is the bug: fix the document in the same change.