8. AG-UI event contract
| Phase | Events the Loco app emits | CopilotKit consumer |
|---|---|---|
| Start | RUN_STARTED{threadId, runId, protocolVersion} | useAgent().agent.isRunning |
| Progress | STATE_SNAPSHOT after each graph step, limited to the workflow's state_whitelist. STEP_STARTED/FINISHED and STATE_DELTA (JSON Patch) are optional refinements | agent.state in useAgent |
| Narration | TEXT_MESSAGE_START/CONTENT/END | <CopilotChat> |
| Tools | TOOL_CALL_START/ARGS/END, then TOOL_CALL_RESULT for server-executed tools | Tool-call render hooks |
| Approval (1.0 client) | STATE_SNAPSHOT → MESSAGES_SNAPSHOT → RUN_FINISHED{outcome: {type: "interrupt", interrupts: [{id, reason: "confirmation", message, metadata.ui}]}} | useInterrupt → next run carries resume[] |
| Approval (legacy client) | CUSTOM{name: settings.agui.legacy_interrupt_event} + plain RUN_FINISHED | Same hook in legacy mode (verify) |
| Done | RUN_FINISHED{outcome: {type: "success"}} | |
| Failure | RUN_ERROR, also used for an expired, invalid or partial resume | Error toast |
Two rules come from the protocol and from CopilotKit issue #8080. Use reason: "confirmation" and never a tool-bound interrupt, so the client does not send a synthetic tool message. Always validate the decision on the server, including role and four-eyes checks from the workflow YAML.