You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In-session feedback capture: an owner's complaint should land as a durable, turn-anchored record — today it lands in memory or nowhere, and the turn it is about is not addressable #514
"User should be able to give feedback to an agent during the session and it should be recorded by
the agent into a separate storage with that timestamp. Should appear in feedback page/tab.
Basically we have to be able to in real-time capture what is wrong with agent so later when we
improve it it can be related to the conversation to get full picture so we could file the tickets
better like we did now."
"Like we did now" is the acceptance test.#503, #504, #505 and #510–#512 were produced last
night by reading transcripts and traces by hand and correlating them to code. This feature succeeds
if it makes that correlation a click, and fails if it only collects opinions.
1. What "like we did now" actually required — the evidence audit
I read the four issues to find out what turned out to be load-bearing, because that is the field
list, and nothing else belongs in it.
The audio clip and what the recognizer heard vs what reached the agent
So a feedback record must carry, at capture time: the instance, a precise timestamp, the message it
is about (and the turn before it), the trace_id that reaches the tool calls, the coding session
if there is one, and — for a voice turn — the audioKey and the dictation. Everything else is
decoration.
Two of those are pure snapshots (message text, preceding turn) and two are pointers (trace_id, audioKey). Section 4 explains why both kinds are needed and neither is sufficient.
returns 30 hits, none of them user feedback: the browser runner's form-validation write-back
(packages/browser-runner/src/runner.ts:786-788,1030), UI hover affordance comments
(store/console/src/lib/control-classes.ts:89), and voice-cue prose (packages/sdk/src/voice/cues.ts:10). grep -rEin "thumbs|rating|upvote|downvote|reaction" finds no rating mechanism on a message
anywhere. gh issue list --state all --search feedback returns nine issues, all about UI
responsiveness (#163, #284) or observability of failures (#424, #291) — none about capturing what
the owner thinks.
There is nothing partial to extend. This is new.
The closest live precedent is the failure mode this feature exists to end: #506, where the owner
asked twice for a bug to be filed and the report ended as write_memory → fact:pending issue:Heartfull:event link shows ID instead of event name, with a
promise nothing schedules and nothing re-reads. That is what a complaint does today when it has no
home.
3. Verified + measured: the correlation key is broken before we add anything
This is the centrepiece, and it is worse than "not yet wired".
A console chat turn produces two different trace ids, and one of them is null.
workers/api/src/routes/instances-chat.ts:99 mints the turn id after the DO has already
answered:
delegation.traceId is populated only by the Loop (workers/api/src/workflows/agent-loop.ts:130, traceId: runId). instances-chat.ts:60-68 passes notraceId in the DO body. So on every
console chat turn, the two events that say "the platform caught its own agent misbehaving" are
written with trace_id = NULL and can never be joined to the chat.in/chat.out pair beside them.
And the transcript is not addressable from the trace at all.AgentMessage
(workers/api/src/agent-types.ts:1-24) has id, role, content, channel, userId, audioKey, dictation, createdAt — no trace id. agent_events (migration 0038) has trace_id but no message id. The only join available is a timestamp.
Measured, live, this account, just now
Instance 5fab318d… (Coder Lead) — the #503 conversation.
instance_messages:
user 846c1695-57a9-4043-b202-beb5ebe718d7 createdAt 2026-08-11T01:57:51.431Z
assistant 06344814-6e21-40c0-a923-2003608d6511 createdAt 2026-08-11T01:57:59.126Z
agent_trace for the same exchange, trace_id d4bd169f-7d1e-45cb-921e-2828d85d3b64:
assistant message → chat.out : 99 ms apart
user message → chat.in : 7,792 ms apart
The chat.in event is stamped with Date.now()after the reply was generated, so the trace's
record of "when the user spoke" is off by the entire turn duration — 7.8 seconds here. Timestamp
matching a user turn to its trace is therefore not merely fuzzy, it is systematically wrong by one
turn, and with concurrent turns on one instance (#429) a 7.8-second window can contain another
exchange entirely.
Nothing downstream can fix this at read time. It has to be stamped at write time, and it is step 1
of this feature. It is also worth landing on its own merits: it repairs the orphaned chat.truncated / chat.invented_result events, which are precisely the rows a future investigator
looks for.
4. Storage — where the record goes, and why not the three obvious places
Not vectors. Retrieval is top-k and fuzzy. A feedback list must be complete and exact; "the
three complaints I made about this agent" is a SELECT, not a similarity search.
Not memory.memoryPrompt is injected into the system prompt on every turn
(workers/api/src/agent-think.ts:304), so a growing feedback log becomes a growing per-turn token
cost — the owner measured his Coder Home memory block at ~19,924 chars ≈ 4,981 tokens/turn (his
measurement, not re-verified by me). Worse, memory steers: #495 is a live instance still
carrying "write access is not enabled" in the same prompt as "[write — consent GRANTED]". A log of
"you got this wrong" injected every turn produces an agent that apologises instead of one that
works.
Not agent_events, despite the tempting fit. Two verified reasons:
Retention.workers/api/src/lib/events.ts:76-82 prunes opportunistically on ~1% of writes: DELETE FROM agent_events WHERE created_at < datetime('now', '-14 days'), and the docstring is
explicit — "The trace is a debugging aid, not an archive." Feedback is exactly an archive: the
owner's sentence is "later when we improve it".
It has no status. Feedback has a life cycle (open → filed → dismissed) and a link to the
issue it became. agent_events is a write-once stream with no mutable column, correctly.
So: a new D1 table, modelled on the two precedents that already answer this shape — error_log (owner-scoped, append-only, its own read route, workers/api/src/routes/errors.ts) and board_items (migration 0036: "This table only persists what the automation can't know: the
human's status override").
Proposed schema
Take the next free migration number at implementation time and run node scripts/check-migrations.mjs before committing — 0119 is the highest on main right now
and several agents are landing migrations today; #369's guard exists because exactly this race
produced two 0092_* files.
CREATETABLEIF NOT EXISTS agent_feedback (
id TEXTPRIMARY KEY,
ts INTEGERNOT NULL, -- ms epoch: ordering, and the join axis to agent_events.ts
created_at TEXTNOT NULL DEFAULT (datetime('now')),
user_id TEXTNOT NULL,
instance_id TEXTNOT NULL,
author TEXTNOT NULL DEFAULT 'user', -- 'user' (console) | 'agent' (record_feedback)
surface TEXTNOT NULL, -- 'chat' | 'coding' | 'board' | 'apply' | 'other'
sentiment TEXT, -- 'bad' | 'good' | NULL (optional, closed set)
body TEXTNOT NULL, -- what the owner said (<= 4000 chars)-- POINTERS: full fidelity while the referents live
trace_id TEXT, -- agent_events.trace_id for this turn
message_id TEXT, -- AgentMessage.id in the instance DO
session_id TEXT, -- coding_sessions.id
timeline_seq INTEGER, -- coding_timeline.seq (migration 0023 — a stable PK)-- SNAPSHOT: survives Clear chat, delete-turn (#342) and the 14-day trace prune
target_role TEXT, -- 'assistant' | 'user' | 'system'
target_text TEXT, -- <= 2000 chars of the message complained about
target_at TEXT, -- its createdAt
prompt_text TEXT, -- <= 1000 chars of the PRECEDING user turn (#505's evidence)
context TEXT, -- JSON: {agentSlug, model, engine, audioKey, dictation, appVersion}-- TRIAGE
status TEXTNOT NULL DEFAULT 'open', -- open | triaged | filed | dismissed
issue_url TEXT, -- what it became (#506's github_create_issue closes the loop here)
updated_at TEXTNOT NULL DEFAULT (datetime('now'))
);
CREATEINDEXIF NOT EXISTS idx_agent_feedback_user_ts ON agent_feedback(user_id, ts DESC);
CREATEINDEXIF NOT EXISTS idx_agent_feedback_instance_ts ON agent_feedback(instance_id, ts DESC);
CREATEINDEXIF NOT EXISTS idx_agent_feedback_trace ON agent_feedback(trace_id);
CREATEINDEXIF NOT EXISTS idx_agent_feedback_status ON agent_feedback(user_id, status, ts DESC);
Pointers AND snapshots, deliberately. A pointer alone dangles: DELETE /v1/instances/:id/messages
removes every message and its R2 audio (workers/api/src/agent-do.ts:855-868), delete-turn removes a
span (:888-901), and the trace self-prunes at 14 days. A snapshot alone loses the tool calls, which
is what #503 and #504 were actually built from. Both, and the read surface renders the snapshot with
a "view full trace" link that degrades honestly when the trace is gone.
No retention sweep on this table. State it in the migration comment so nobody adds one by
symmetry with error_log/agent_events.
5. Fork 1 — who records it: the model, or the UI?
Recommendation: both, with the UI as the load-bearing path and the tool as the conversational
convenience. I agree with the framing in the brief and here is the argument.
The owner's words are "recorded by the agent", which reads as a tool. But the turn on which an
agent is malfunctioning is the turn least able to correctly call a tool about its own
malfunction, and this repo already contains the proof: #504 — a Pilot that emitted 11 empty tool_use blocks out of 19 would not have emitted a correct record_feedback call either; and #503 — an agent whose tool results were being silently truncated could not even report the
truncation as a fact. A capture path that runs inside the failing turn is a capture path that is
absent exactly when it matters.
So:
A. Deterministic UI affordance (the guarantee). A control on a message that POSTs a row. No
model in the path, no tokens, cannot fail for the reason being reported.
The tool must stamp the SAME row shape, with author='agent', trace_id from RegistryToolCtx.traceId (workers/api/src/lib/connectors/types.ts:42, already threaded), and the
model's own paraphrase in body — not a summary of what it thinks the problem is. create_ticket
(workers/api/src/lib/tool-registry.ts:365) is the template: tier: "base", deferred import,
owner-scoped from ctx.instanceId/ctx.userId.
What would change my mind: if the owner would rather have zero new chrome on the message bubble
(section 7 shows it is crowded), the tool alone plus a composer-level /feedback command is
defensible — but then the capture rate depends on a working model, and the first feedback we lose
will be about the model not working.
6. Fork 2 — feedback is a bug report, NOT a correction the agent applies
This is the boundary that must be stated in the code, not just decided here, because an
implementer who blurs it builds a second, worse memory system — and this repo has already paid for
that twice (#226 stored preference:response_style in memory; #495 is memory-rot steering a live
agent today).
Three destinations already exist and none of them is this one:
The owner says
Goes to
Where
"be less technical", "stop ending with a question"
"always check the CI before you say it's deployed"
Rules & Tips
agent_instances.config.specialInstructions
"the admin app is the one with the version gate"
Memory
write_memory
"you just told me I chose that — I never said it"
Feedback
this table
Feedback is the class where the platform is at fault and no configuration fixes it. It is
evidence for a ticket, and it is never read back as instruction.
The prompt already carries the precedent paragraph for this, at workers/api/src/agent-think.ts:314-318 ("MEMORY vs BEHAVIOUR: memory is for facts about the
SUBJECT you work on…"). Extend it, in the same place and voice:
FEEDBACK vs MEMORY vs BEHAVIOUR: when the user tells you that YOU got something wrong — a
wrong answer, an action you claimed but did not take, a step that failed silently — call record_feedback. That is a report about the platform, kept for the people who improve it; it is
not a fact to remember and not a manner to adopt. Do not write it to memory, do not add it to
your behaviour, and do not promise to "remember it for next time". Record it, say you have, and
carry on with the task.
The last clause is deliberate: #506's agent promised to file something later and nothing scheduled
the promise.
7. The read surface
Recommendation: a per-instance feedback tab AND a platform-wide /feedback page, served by one
route. Build the tab first if only one can ship.
The page goes at /feedback in store/console/src/App.tsx beside /terminals and /usage
(:56-57) with a nav entry in Layout.tsx:19-20. This is the triage view — "everything I have
flagged, across every agent, still open" — and it is the one the owner will actually use when
filing.
One route serves both, following errors.ts exactly (errorRoutes.get("/") is user-scoped
with optional filters): GET /v1/feedback?instance_id=&status=&limit=. The tab passes instance_id, the page does not.
A row renders: timestamp, agent name, the owner's words, the quoted target message, status, and two
links — "open the conversation" (deep link to the instance chat) and "open the trace"
(/v1/instances/:id/trace?trace_id=…). Those two links are the whole feature; without them this is
a suggestion box.
8. MCP — the tools that close the loop
Without these the feature stops at collection and never reaches "file the tickets better". A pags-ba agent must be able to pull feedback the way it pulls instance_messages today.
Register in workers/mcp/src/instance-tools/observability.ts — it is already the home of instance_messages, agent_trace and list_errors, and its docstring says so:
Housekeeping the implementer must not miss:workers/mcp/src/tool-count.ts — bump MCP_TOOL_COUNT 133 → 135 and MCP_TOOL_ALWAYS_ON 115 → 117, and update the "N tools" prose in platform-docs/mcp.md, store/llms-full.txt and workers/mcp/README.md, or index.test.ts and scripts/docs-drift.mjs fail.
9. Voice — capturable, but never load-bearing
The owner talks to these agents, so spoken feedback matters. Two decisions:
Do NOT add a feedback voice command.VoiceCommand is a closed union of seven
(packages/sdk/src/voice/convo.ts:157) and every one of them is a distinctive word chosen so a
whole-phrase match cannot hijack ordinary speech ("repeat", "scrap", "exit"). The natural phrases
for feedback — "that's wrong", "no, that's not right" — are ordinary speech, spoken constantly
during normal use. A command word there would fire mid-sentence and turn half a conversation into
feedback rows. (matchVoiceCommand at convo.ts:516 and the per-language tables at :755-762 are
where this would go, and should not.)
Instead: the feedback composer is a text field the existing STT can dictate into, exactly like
the chat composer, plus typing. And it must record voice provenance:
When the target message carries audioKey and/or dictation (agent-types.ts:9-17), copy BOTH
into context.
That is not a nicety — it is the only way a voice bug is filable at all, and #510–#512 are three
live examples. From the same live pull as section 3, the user turn that produced #503:
content : "…how many agents do you have in total? Give me their names."
dictation : "…how many ages does Kevin total give me the next"
Feedback on that turn without the dictation field is a report about the agent; with it, it is a
report about the recogniser.
Honest limitation to write into the issue and the UI: the R2 audio blob is deleted by Clear chat
and delete-turn (agent-do.ts:855-868, :888-901), so a stored audioKey may dangle. The record
says a recording existed and names its key; the blob keeps the transcript's lifetime. Do not build a
second retention rule for it — that is the mistake dictation was moved onto the message to avoid.
And the standing constraint: voice capture itself is currently unreliable (#510, #511, #512, #425). Nothing in this feature may depend on a working microphone. The UI path (section 5A) is the
guarantee precisely because it does not.
10. Deletion, edit, and whether the agent can see it
Hard-deletable by the owner. It survives Clear chat by design, it can contain anything he
typed including PII or a pasted secret, so "delete my data" must reach it. DELETE /v1/feedback/:id,
owner-scoped.
NOT visible to the agent. No injection into the system prompt (that is [bug] The summariser stores transient platform state as permanent memory — an instance still carries "write access is not enabled" while its own prompt says consent is GRANTED #495 by another
route, plus unbounded per-turn tokens — section 4), and no agent read tool in v1. An agent
that reads complaints about itself starts answering them: apologising, over-correcting, and
treating a bug report as a standing instruction, which is exactly the boundary section 6 draws.
The owner-authenticated MCP tools are the read seam, so a supervisor or BA agent can still get
at it — deliberately out-of-band, never in the prompt of the agent being complained about.
11. Plan — cheapest first, each step shippable
Step 1 — make a chat turn addressable (independently valuable; land it alone if you like).
workers/api/src/routes/instances-chat.ts: mint const turnId = crypto.randomUUID() and capture const startedAt = Date.now()before the DO fetch; pass traceId: turnId in the body (the
field is already read — agent-do.tsdelegation.traceId, and agent-loop.ts:130 already
supplies it); use startedAt for the chat.in event's ts.
workers/api/src/agent-types.ts: add optional traceId?: string to AgentMessage; stamp it on
both the user and assistant messages of the turn in agent-do.tsrunTurn; return it from /messages.
Result: chat.truncated and chat.invented_result stop being orphans, chat.in stops being 7.8s
late, and the console holds the trace id for the next step.
Step 2 — the store + capture route. Migration (next free number, guard-checked), workers/api/src/lib/feedback.ts (pure: validate, clamp, build the row), new router workers/api/src/routes/feedback.ts mounted at /v1/feedback — POST /, GET /, PATCH /:id, DELETE /:id; requireUser + requireOwnedInstance for the instance scope; default rate limit
(240/min) is fine. Add the four paths to store/openapi.yaml or scripts/openapi-coverage.mjs
fails.
Step 3 — the capture affordance. See the layout note below; smallest correct version first.
Step 4 — the read surface.FeedbackTab.tsx + a feedback entry in SURFACES; then pages/Feedback.tsx + the route and nav entry. Same fetch, two callers.
Step 5 — the agent tool.record_feedback in tool-registry.ts beside create_ticket, added
to BASE in workers/api/src/agent-do-tools.ts:15-70 with a comment in that file's established
style, plus the prompt paragraph from section 6.
Step 6 — MCP.list_feedback + resolve_feedback, tool-count and docs-drift updates.
The layout note the implementer needs before step 3
The assistant bubble corner already holds two controls and the measurement is written down at store/console/src/pages/InstanceDetail.tsx:1145-1166: Copy at right-1, Delete at right-8, and
"Delete's outer edge is 56px from the bubble border (right-8 + its 24px box) against a content
box starting 12px in, so 44px must be reserved and pr-12 is 48."
Below sm both are permanently visible (there is no hover on touch), and #426 was the timestamp
being covered. A third icon at right-14 puts the outer edge at 80px → 68px must be reserved → pr-20, on a bubble that is 200px wide at 320px viewport.
Cheapest (do this first): third icon, pr-20 below sm, and re-measure the stamp in WebKit
at 320px — the e2e guard runs Chromium and this is the layout class that only breaks in WebKit.
If the stamp wraps to three lines or clips, it fails.
For the coding surface, the anchor is session_id + coding_timeline.seq (migration 0023 — seq is an AUTOINCREMENT PK, so it is already a stable address), and the affordance belongs on a
Co-pilot turn.
12. Alternatives considered and rejected
Reuse agent_events with source:"feedback". Rejected: the 14-day opportunistic prune
(lib/events.ts:76-82) deletes the archive this feature exists to build, and the table has no
mutable status column for triage. (If the owner prefers one table anyway, the change is to
exempt feedback-referenced traces from the prune and add a status column — but then agent_events
is no longer "a debugging aid, not an archive", and its docstring has to change to say so.)
Make it a board ticket (create_ticket). Rejected: the board is work the agent will do.
Feedback is work we will do, on the platform, and it must not compete for the agent's
attention or sit in a Needs-approval column forever. They do connect — a feedback row can become a
GitHub issue via github_create_issue — but through issue_url, not by being the same object.
A feedback voice command word. Rejected in section 9: the natural phrases are ordinary speech.
Auto-capturing platform-detected defects as feedback (chat.truncated, chat.invented_result, fabricated). Deliberately out of scope: feedback is what the human
said. Those events already exist in the trace, and mixing machine-detected and human-authored rows
in one list makes the human ones unfindable. Worth a follow-up as a separate "suspected defects"
view.
13. Acceptance criteria
A console chat turn's user message, assistant message, and its chat.in / tool.call / chat.out / chat.truncated / chat.invented_result events all carry the sametrace_id.
Checkable: send a message, read /messages and /trace, compare.
chat.in's ts is within 250 ms of the user message's createdAt (today: 7,792 ms on the
measured turn).
Clicking the feedback control on any message, typing a sentence, and submitting creates exactly
one agent_feedback row whose message_id, trace_id, target_text, target_at and prompt_text are populated from that turn — verified with the model API mocked out entirely,
i.e. the path does not touch a model.
Feedback given on a voice turn has audioKey and dictation in context when the message
carries them.
GET /v1/feedback returns the row for the owner and 404/empty for a different user; the same
route with ?instance_id= returns only that instance's.
Clearing the chat (DELETE /v1/instances/:id/messages) leaves the feedback row intact and its target_text still readable; the read surface shows the conversation link as unavailable rather
than erroring.
The Feedback tab appears on every agent, including one with surfaces: [] (e.g. Coder Lead 5fab318d…) and one with surfaces:["repo"].
list_feedback over MCP returns rows including trace_id; passing that trace_id to agent_trace returns the tool calls of the complained-about turn. This is the "like we did
now" criterion — demonstrate it end to end on a real complaint.
resolve_feedback sets status:"filed" + issue_url and is refused without the MCP write
scope.
An agent asked "remember that you got that wrong" calls record_feedback and does not call write_memory — assert on the tool calls, not on the prose.
No feedback text appears anywhere in the built system prompt. A unit test over the prompt
builder with rows present is the check.
node scripts/check-migrations.mjs, scripts/openapi-coverage.mjs, docs-drift.mjs and check-file-size.mjs all pass; MCP_TOOL_COUNT matches the real registration count.
existing agent-do / message-page / delete-turn suites — the field is optional and additive, so a failure here means something is comparing whole message objects
Minting turnId before the DO call
events are currently written only inside if (doRes.ok); keep that. A failed turn now could be traced, which is an improvement, but do not change error behaviour in the same step
routes/chat.integration.test.ts; no test currently asserts the ts values (grepped)
chat.ints = startedAt
trace ordering (listEvents sorts by ts)
ordering holds because startedAt < now; assert oldest→newest in the trace test
A third bubble control
#426 (timestamp covered below sm), #389 (44px tap targets), #342 (Delete must not be swallowed by a wider neighbour — its own comment warns that later-painted overlays win the hit test)
a WebKit 320px measurement, per the note in step 3; the Chromium e2e will not catch it
A 13th tab
mobile tab strip is icon-only + scrollable, so this is width, not overflow
visual check at 320px
record_feedback in BASE
every agent's tool list grows by one; a declared capabilities.tools allowlist does not exclude it (BASE is always added — agent-do-tools.ts:187-193), which is intended
tool-reachability.test.ts; note it asks "does SOME agent declare this?", so it cannot catch a per-agent gap (#506's lesson)
New MCP tools
index.test.ts counts registrations; docs-drift.mjs reads three docs
both fail loudly if tool-count.ts is not bumped
15. Open questions for the owner
Sentiment field — keep or drop? I have included an optional sentiment: 'bad'|'good' because
a one-tap 👎 costs nothing and catches feedback that would otherwise not be typed. It is not
needed for ticket-filing. I would keep it, and make the tap open the composer rather than submit
silently — a bare thumbs-down with no words is not filable evidence.
Should a supervisor (Coder Lead) get a read_feedback agent tool so it can triage complaints
about its subordinates? I would defer this until there is volume: it is the one path that puts
feedback back into a prompt, and it should be decided on evidence, not in advance.
Anonymous/creator-visible feedback. Everything above is owner-private. When PAGS has external
subscribers, "should a creator see feedback on their published agent?" becomes a real product
question with a privacy answer attached. Out of scope here; the schema does not preclude it
(the agent id is reachable via instance_id).
The owner's request
"Like we did now" is the acceptance test. #503, #504, #505 and #510–#512 were produced last
night by reading transcripts and traces by hand and correlating them to code. This feature succeeds
if it makes that correlation a click, and fails if it only collects opinions.
1. What "like we did now" actually required — the evidence audit
I read the four issues to find out what turned out to be load-bearing, because that is the field
list, and nothing else belongs in it.
list_subordinates, notsubordinate_status)trace_id(2ab928b6…) and the per-step messages with timestamps — 11 of 19 emptySo a feedback record must carry, at capture time: the instance, a precise timestamp, the message it
is about (and the turn before it), the
trace_idthat reaches the tool calls, the coding sessionif there is one, and — for a voice turn — the
audioKeyand thedictation. Everything else isdecoration.
Two of those are pure snapshots (message text, preceding turn) and two are pointers (
trace_id,audioKey). Section 4 explains why both kinds are needed and neither is sufficient.2. Verified: nothing like this exists today
returns 30 hits, none of them user feedback: the browser runner's form-validation write-back
(
packages/browser-runner/src/runner.ts:786-788,1030), UI hover affordance comments(
store/console/src/lib/control-classes.ts:89), and voice-cue prose (packages/sdk/src/voice/cues.ts:10).grep -rEin "thumbs|rating|upvote|downvote|reaction"finds no rating mechanism on a messageanywhere.
gh issue list --state all --search feedbackreturns nine issues, all about UIresponsiveness (#163, #284) or observability of failures (#424, #291) — none about capturing what
the owner thinks.
There is nothing partial to extend. This is new.
The closest live precedent is the failure mode this feature exists to end: #506, where the owner
asked twice for a bug to be filed and the report ended as
write_memory → fact:pending issue:Heartfull:event link shows ID instead of event name, with apromise nothing schedules and nothing re-reads. That is what a complaint does today when it has no
home.
3. Verified + measured: the correlation key is broken before we add anything
This is the centrepiece, and it is worse than "not yet wired".
A console chat turn produces two different trace ids, and one of them is
null.workers/api/src/routes/instances-chat.ts:99mints the turn id after the DO has alreadyanswered:
Meanwhile the DO's own warn-level events about that same turn use a different source:
workers/api/src/agent-think.ts:774—chat.truncated→traceId: delegation?.traceId ?? nullworkers/api/src/agent-think.ts:908—chat.invented_result→traceId: delegation?.traceId ?? nulldelegation.traceIdis populated only by the Loop (workers/api/src/workflows/agent-loop.ts:130,traceId: runId).instances-chat.ts:60-68passes notraceIdin the DO body. So on everyconsole chat turn, the two events that say "the platform caught its own agent misbehaving" are
written with
trace_id = NULLand can never be joined to thechat.in/chat.outpair beside them.And the transcript is not addressable from the trace at all.
AgentMessage(
workers/api/src/agent-types.ts:1-24) hasid,role,content,channel,userId,audioKey,dictation,createdAt— no trace id.agent_events(migration0038) hastrace_idbut no message id. The only join available is a timestamp.Measured, live, this account, just now
Instance
5fab318d…(Coder Lead) — the #503 conversation.instance_messages:agent_tracefor the same exchange,trace_id d4bd169f-7d1e-45cb-921e-2828d85d3b64:The
chat.inevent is stamped withDate.now()after the reply was generated, so the trace'srecord of "when the user spoke" is off by the entire turn duration — 7.8 seconds here. Timestamp
matching a user turn to its trace is therefore not merely fuzzy, it is systematically wrong by one
turn, and with concurrent turns on one instance (#429) a 7.8-second window can contain another
exchange entirely.
Nothing downstream can fix this at read time. It has to be stamped at write time, and it is step 1
of this feature. It is also worth landing on its own merits: it repairs the orphaned
chat.truncated/chat.invented_resultevents, which are precisely the rows a future investigatorlooks for.
4. Storage — where the record goes, and why not the three obvious places
Not vectors. Retrieval is top-k and fuzzy. A feedback list must be complete and exact; "the
three complaints I made about this agent" is a
SELECT, not a similarity search.Not memory.
memoryPromptis injected into the system prompt on every turn(
workers/api/src/agent-think.ts:304), so a growing feedback log becomes a growing per-turn tokencost — the owner measured his Coder Home memory block at ~19,924 chars ≈ 4,981 tokens/turn (his
measurement, not re-verified by me). Worse, memory steers: #495 is a live instance still
carrying "write access is not enabled" in the same prompt as "[write — consent GRANTED]". A log of
"you got this wrong" injected every turn produces an agent that apologises instead of one that
works.
Not
agent_events, despite the tempting fit. Two verified reasons:workers/api/src/lib/events.ts:76-82prunes opportunistically on ~1% of writes:DELETE FROM agent_events WHERE created_at < datetime('now', '-14 days'), and the docstring isexplicit — "The trace is a debugging aid, not an archive." Feedback is exactly an archive: the
owner's sentence is "later when we improve it".
issue it became.
agent_eventsis a write-once stream with no mutable column, correctly.So: a new D1 table, modelled on the two precedents that already answer this shape —
error_log(owner-scoped, append-only, its own read route,workers/api/src/routes/errors.ts) andboard_items(migration0036: "This table only persists what the automation can't know: thehuman's status override").
Proposed schema
Take the next free migration number at implementation time and run
node scripts/check-migrations.mjsbefore committing —0119is the highest onmainright nowand several agents are landing migrations today; #369's guard exists because exactly this race
produced two
0092_*files.Pointers AND snapshots, deliberately. A pointer alone dangles:
DELETE /v1/instances/:id/messagesremoves every message and its R2 audio (
workers/api/src/agent-do.ts:855-868), delete-turn removes aspan (
:888-901), and the trace self-prunes at 14 days. A snapshot alone loses the tool calls, whichis what #503 and #504 were actually built from. Both, and the read surface renders the snapshot with
a "view full trace" link that degrades honestly when the trace is gone.
No retention sweep on this table. State it in the migration comment so nobody adds one by
symmetry with
error_log/agent_events.5. Fork 1 — who records it: the model, or the UI?
Recommendation: both, with the UI as the load-bearing path and the tool as the conversational
convenience. I agree with the framing in the brief and here is the argument.
The owner's words are "recorded by the agent", which reads as a tool. But the turn on which an
agent is malfunctioning is the turn least able to correctly call a tool about its own
malfunction, and this repo already contains the proof: #504 — a Pilot that emitted 11 empty
tool_useblocks out of 19 would not have emitted a correctrecord_feedbackcall either; and#503 — an agent whose tool results were being silently truncated could not even report the
truncation as a fact. A capture path that runs inside the failing turn is a capture path that is
absent exactly when it matters.
So:
model in the path, no tokens, cannot fail for the reason being reported.
record_feedbackagent tool (the ergonomics). Because the owner will say "that's wrong,write that down" mid-conversation, hands-free, and the alternative is not silence — it is
write_memory, which is what [bug] The Coder Lead cannot file a GitHub issue — asked directly, it says so, and the bug report ends up in a memory key nothing ever reads #506 and Agent Behaviour: stop character leaking into Memory; migrate preference:* and responseStyle #226 both did. A tool is how the platform stops a complaintlanding in the wrong store.
The tool must stamp the SAME row shape, with
author='agent',trace_idfromRegistryToolCtx.traceId(workers/api/src/lib/connectors/types.ts:42, already threaded), and themodel's own paraphrase in
body— not a summary of what it thinks the problem is.create_ticket(
workers/api/src/lib/tool-registry.ts:365) is the template:tier: "base", deferred import,owner-scoped from
ctx.instanceId/ctx.userId.What would change my mind: if the owner would rather have zero new chrome on the message bubble
(section 7 shows it is crowded), the tool alone plus a composer-level
/feedbackcommand isdefensible — but then the capture rate depends on a working model, and the first feedback we lose
will be about the model not working.
6. Fork 2 — feedback is a bug report, NOT a correction the agent applies
This is the boundary that must be stated in the code, not just decided here, because an
implementer who blurs it builds a second, worse memory system — and this repo has already paid for
that twice (#226 stored
preference:response_stylein memory; #495 is memory-rot steering a liveagent today).
Three destinations already exist and none of them is this one:
set_behaviour,workers/api/src/lib/agent-behaviour.tsagent_instances.config.specialInstructionswrite_memoryFeedback is the class where the platform is at fault and no configuration fixes it. It is
evidence for a ticket, and it is never read back as instruction.
The prompt already carries the precedent paragraph for this, at
workers/api/src/agent-think.ts:314-318("MEMORY vs BEHAVIOUR: memory is for facts about theSUBJECT you work on…"). Extend it, in the same place and voice:
The last clause is deliberate: #506's agent promised to file something later and nothing scheduled
the promise.
7. The read surface
Recommendation: a per-instance
feedbacktab AND a platform-wide/feedbackpage, served by oneroute. Build the tab first if only one can ship.
store/console/src/lib/surfaces.tsxSURFACESwithshow: () => true—the same universal treatment as Behaviour (
:265-278) and Stats (:245-256), and for the samestated reason: every agent can be complained about, so gating it on
capabilities.surfaceswouldhide it exactly where a new agent type most needs it. It must be a tab, not a Knowledge/Activity
sub-tab: [bug] Knowledge → Documents, Files and Index are shown on agents that declare no tool to read them — the gate the Indexing surface got was never applied one level in #509 landed last night on precisely the failure of hiding a surface one level inside a
composite.
/feedbackinstore/console/src/App.tsxbeside/terminalsand/usage(
:56-57) with a nav entry inLayout.tsx:19-20. This is the triage view — "everything I haveflagged, across every agent, still open" — and it is the one the owner will actually use when
filing.
errors.tsexactly (errorRoutes.get("/")is user-scopedwith optional filters):
GET /v1/feedback?instance_id=&status=&limit=. The tab passesinstance_id, the page does not.A row renders: timestamp, agent name, the owner's words, the quoted target message, status, and two
links — "open the conversation" (deep link to the instance chat) and "open the trace"
(
/v1/instances/:id/trace?trace_id=…). Those two links are the whole feature; without them this isa suggestion box.
8. MCP — the tools that close the loop
Without these the feature stops at collection and never reaches "file the tickets better". A
pags-baagent must be able to pull feedback the way it pullsinstance_messagestoday.Register in
workers/mcp/src/instance-tools/observability.ts— it is already the home ofinstance_messages,agent_traceandlist_errors, and its docstring says so:list_feedback(read) —{instance_id?, status?, limit?}. Same shape aslist_errors.Returns rows including
trace_idandmessage_id, so the caller's next two calls areagent_trace(trace_id=…)andinstance_messages(...)— which is exactly the manual sequencethat produced [bug] The Coder Lead can only ever see 3 of 6 agents — subordinate_status returns 60,239 chars into a 24,000 cap, so half the roster is silently dropped #503–[bug] The Pilot overrode the engine's correct objection and reported it to the owner as his own explicit choice — the deploy it broke was never his decision #505, minus the archaeology.
resolve_feedback(write) —{feedback_id, status, issue_url?}. Gated throughrequirePermission(safetyFor(token), "write", …)+audit(...)like every mutating MCP tool.This is what makes the backlog honest: the BA files with
github_create_issue(granted tocoder-leadyesterday, migration0119/ [bug] The Coder Lead cannot file a GitHub issue — asked directly, it says so, and the bug report ends up in a memory key nothing ever reads #506) and then stamps the feedback row with the URL,so the same complaint is not filed twice and an unfiled one stays visible.
Housekeeping the implementer must not miss:
workers/mcp/src/tool-count.ts— bumpMCP_TOOL_COUNT133 → 135 andMCP_TOOL_ALWAYS_ON115 → 117, and update the "N tools" prose inplatform-docs/mcp.md,store/llms-full.txtandworkers/mcp/README.md, orindex.test.tsandscripts/docs-drift.mjsfail.9. Voice — capturable, but never load-bearing
The owner talks to these agents, so spoken feedback matters. Two decisions:
Do NOT add a
feedbackvoice command.VoiceCommandis a closed union of seven(
packages/sdk/src/voice/convo.ts:157) and every one of them is a distinctive word chosen so awhole-phrase match cannot hijack ordinary speech ("repeat", "scrap", "exit"). The natural phrases
for feedback — "that's wrong", "no, that's not right" — are ordinary speech, spoken constantly
during normal use. A command word there would fire mid-sentence and turn half a conversation into
feedback rows. (
matchVoiceCommandatconvo.ts:516and the per-language tables at:755-762arewhere this would go, and should not.)
Instead: the feedback composer is a text field the existing STT can dictate into, exactly like
the chat composer, plus typing. And it must record voice provenance:
That is not a nicety — it is the only way a voice bug is filable at all, and #510–#512 are three
live examples. From the same live pull as section 3, the user turn that produced #503:
Feedback on that turn without the
dictationfield is a report about the agent; with it, it is areport about the recogniser.
Honest limitation to write into the issue and the UI: the R2 audio blob is deleted by Clear chat
and delete-turn (
agent-do.ts:855-868,:888-901), so a storedaudioKeymay dangle. The recordsays a recording existed and names its key; the blob keeps the transcript's lifetime. Do not build a
second retention rule for it — that is the mistake
dictationwas moved onto the message to avoid.And the standing constraint: voice capture itself is currently unreliable (#510, #511, #512,
#425). Nothing in this feature may depend on a working microphone. The UI path (section 5A) is the
guarantee precisely because it does not.
10. Deletion, edit, and whether the agent can see it
Three decisions, each with its reason:
at a moment. Editing the body destroys the only property that makes it evidence — and [bug] The Pilot overrode the engine's correct objection and reported it to the owner as his own explicit choice — the deploy it broke was never his decision #505 is
specifically about a record that did not match what was said.
statusandissue_urlaremutable,
updated_atmoves; no audit sub-table (over-engineering at this volume).typed including PII or a pasted secret, so "delete my data" must reach it.
DELETE /v1/feedback/:id,owner-scoped.
route, plus unbounded per-turn tokens — section 4), and no agent read tool in v1. An agent
that reads complaints about itself starts answering them: apologising, over-correcting, and
treating a bug report as a standing instruction, which is exactly the boundary section 6 draws.
The owner-authenticated MCP tools are the read seam, so a supervisor or BA agent can still get
at it — deliberately out-of-band, never in the prompt of the agent being complained about.
11. Plan — cheapest first, each step shippable
Step 1 — make a chat turn addressable (independently valuable; land it alone if you like).
workers/api/src/routes/instances-chat.ts: mintconst turnId = crypto.randomUUID()and captureconst startedAt = Date.now()before the DO fetch; passtraceId: turnIdin the body (thefield is already read —
agent-do.tsdelegation.traceId, andagent-loop.ts:130alreadysupplies it); use
startedAtfor thechat.inevent'sts.workers/api/src/agent-types.ts: add optionaltraceId?: stringtoAgentMessage; stamp it onboth the user and assistant messages of the turn in
agent-do.tsrunTurn; return it from/messages.chat.truncatedandchat.invented_resultstop being orphans,chat.instops being 7.8slate, and the console holds the trace id for the next step.
Step 2 — the store + capture route. Migration (next free number, guard-checked),
workers/api/src/lib/feedback.ts(pure: validate, clamp, build the row), new routerworkers/api/src/routes/feedback.tsmounted at/v1/feedback—POST /,GET /,PATCH /:id,DELETE /:id;requireUser+requireOwnedInstancefor the instance scope; default rate limit(240/min) is fine. Add the four paths to
store/openapi.yamlorscripts/openapi-coverage.mjsfails.
Step 3 — the capture affordance. See the layout note below; smallest correct version first.
Step 4 — the read surface.
FeedbackTab.tsx+ afeedbackentry inSURFACES; thenpages/Feedback.tsx+ the route and nav entry. Same fetch, two callers.Step 5 — the agent tool.
record_feedbackintool-registry.tsbesidecreate_ticket, addedto
BASEinworkers/api/src/agent-do-tools.ts:15-70with a comment in that file's establishedstyle, plus the prompt paragraph from section 6.
Step 6 — MCP.
list_feedback+resolve_feedback, tool-count and docs-drift updates.The layout note the implementer needs before step 3
The assistant bubble corner already holds two controls and the measurement is written down at
store/console/src/pages/InstanceDetail.tsx:1145-1166: Copy atright-1, Delete atright-8, andBelow
smboth are permanently visible (there is no hover on touch), and #426 was the timestampbeing covered. A third icon at
right-14puts the outer edge at 80px → 68px must be reserved →pr-20, on a bubble that is 200px wide at 320px viewport.pr-20belowsm, and re-measure the stamp in WebKitat 320px — the e2e guard runs Chromium and this is the layout class that only breaks in WebKit.
If the stamp wraps to three lines or clips, it fails.
⋮per-message overflow below
sm, keepingpr-12, and keep three hover icons abovesm. Morework, and it touches controls that Delete a single turn — by button and by voice ("scrap that") — because a noise message keeps shaping every later reply #342/[a11y][mobile] 40 interactive elements per screen are under 40px — 12px checkboxes on Behaviour, 16px Remove on Repo, 24px message actions #389/[bug][mobile] Copy and Delete sit on top of the message timestamp — 42px of 110px covered in WebKit, and removing the year only recovers 30px #426 deliberately shaped — so it needs their
promises re-read first, not just a diff.
For the coding surface, the anchor is
session_id+coding_timeline.seq(migration0023—seqis an AUTOINCREMENT PK, so it is already a stable address), and the affordance belongs on aCo-pilot turn.
12. Alternatives considered and rejected
agent_eventswithsource:"feedback". Rejected: the 14-day opportunistic prune(
lib/events.ts:76-82) deletes the archive this feature exists to build, and the table has nomutable status column for triage. (If the owner prefers one table anyway, the change is to
exempt feedback-referenced traces from the prune and add a status column — but then
agent_eventsis no longer "a debugging aid, not an archive", and its docstring has to change to say so.)
on every turn (
agent-think.ts:304).create_ticket). Rejected: the board is work the agent will do.Feedback is work we will do, on the platform, and it must not compete for the agent's
attention or sit in a Needs-approval column forever. They do connect — a feedback row can become a
GitHub issue via
github_create_issue— but throughissue_url, not by being the same object.precisely the bug of hiding a surface one level inside a composite.
chat.truncated,chat.invented_result,fabricated). Deliberately out of scope: feedback is what the humansaid. Those events already exist in the trace, and mixing machine-detected and human-authored rows
in one list makes the human ones unfindable. Worth a follow-up as a separate "suspected defects"
view.
13. Acceptance criteria
chat.in/tool.call/chat.out/chat.truncated/chat.invented_resultevents all carry the sametrace_id.Checkable: send a message, read
/messagesand/trace, compare.chat.in'stsis within 250 ms of the user message'screatedAt(today: 7,792 ms on themeasured turn).
one
agent_feedbackrow whosemessage_id,trace_id,target_text,target_atandprompt_textare populated from that turn — verified with the model API mocked out entirely,i.e. the path does not touch a model.
audioKeyanddictationincontextwhen the messagecarries them.
GET /v1/feedbackreturns the row for the owner and 404/empty for a different user; the sameroute with
?instance_id=returns only that instance's.DELETE /v1/instances/:id/messages) leaves the feedback row intact and itstarget_textstill readable; the read surface shows the conversation link as unavailable ratherthan erroring.
surfaces: [](e.g. Coder Lead5fab318d…) and one withsurfaces:["repo"].list_feedbackover MCP returns rows includingtrace_id; passing thattrace_idtoagent_tracereturns the tool calls of the complained-about turn. This is the "like we didnow" criterion — demonstrate it end to end on a real complaint.
resolve_feedbacksetsstatus:"filed"+issue_urland is refused without the MCPwritescope.
record_feedbackand does not callwrite_memory— assert on the tool calls, not on the prose.builder with rows present is the check.
node scripts/check-migrations.mjs,scripts/openapi-coverage.mjs,docs-drift.mjsandcheck-file-size.mjsall pass;MCP_TOOL_COUNTmatches the real registration count.14. Regression risk
traceIdonAgentMessage/messagesreaders,turnSpanFor(#342 delete-turn),lib/message-page.tscursors,lib/fabricated-history.tsagent-do/ message-page / delete-turn suites — the field is optional and additive, so a failure here means something is comparing whole message objectsturnIdbefore the DO callif (doRes.ok); keep that. A failed turn now could be traced, which is an improvement, but do not change error behaviour in the same steproutes/chat.integration.test.ts; no test currently asserts thetsvalues (grepped)chat.ints=startedAtlistEventssorts byts)startedAt < now; assert oldest→newest in the trace testsm), #389 (44px tap targets), #342 (Delete must not be swallowed by a wider neighbour — its own comment warns that later-painted overlays win the hit test)record_feedbackinBASEcapabilities.toolsallowlist does not exclude it (BASE is always added —agent-do-tools.ts:187-193), which is intendedtool-reachability.test.ts; note it asks "does SOME agent declare this?", so it cannot catch a per-agent gap (#506's lesson)index.test.tscounts registrations;docs-drift.mjsreads three docstool-count.tsis not bumped15. Open questions for the owner
sentiment: 'bad'|'good'becausea one-tap 👎 costs nothing and catches feedback that would otherwise not be typed. It is not
needed for ticket-filing. I would keep it, and make the tap open the composer rather than submit
silently — a bare thumbs-down with no words is not filable evidence.
read_feedbackagent tool so it can triage complaintsabout its subordinates? I would defer this until there is volume: it is the one path that puts
feedback back into a prompt, and it should be decided on evidence, not in advance.
subscribers, "should a creator see feedback on their published agent?" becomes a real product
question with a privacy answer attached. Out of scope here; the schema does not preclude it
(the agent id is reachable via
instance_id).