Copilot agent for OpenBB Workspace. Hono + Bun + Vercel AI SDK + MCP.
For architecture, tool inventory, file layout, env vars, request flow, the MCP and citation protocols, and the test strategy, read README.md. This file is rules, conventions, and gotchas only.
bun run dev:agent # agent on :7777
bun run dev:mcp # MCP server on :8787
bun run typecheck # bunx tsc --noEmit — must be zero errors
bun run test # bun test tests/ — must be all green before mergePrefer bun
The agent owns: protocol round-trips, widget tier/parse, citation aggregation, system prompt, state-bound capabilities (SQL family on pendingTables + artifactQueue, workspace bridge ops on the vendored bridge contract). Stateless / heavy / cold-start capabilities live in the MCP server. New capability needs in-process state the workspace can't reach → it's an agent tool; otherwise add it to mcp-server/.
- No content-based routing in the harness. No
isStructured(), noif data looks like X then path A else path B. Surface tools, let the model pick. Generic dispatch by tool source (in-process vs SSE round-trip vs MCP) is structural, not content-based. - Never open MCP connections from the agent. The workspace owns MCP. The agent emits
executeAgentToolSSE for external MCP tools, nativecopilotFunctionCall { function: <command> }for the 16 vendored workspace bridge ops, or runs locally (search_widgets, SQL family). - One
streamTextper iteration. The outerMAX_LOOPS=3exists only for cache-resolved widget re-fetches without an HTTP round-trip — not for orchestration. (streamText, notgenerateText: the loop consumesfullStreamso in-process tool chains stream progress live; a stalled call is bounded byLLM_STREAM_TIMEOUT_MS.) - Tools have
executeonly when the work is local + cheap, or state-bound to in-process data. Today that'ssearch_widgets(closure ontieredWidgets— primary/secondary/extra preserved for ranking) and the SQL family (closure onpendingTables+artifactQueue). Everything else (widget data, skill, workspace bridge ops, MCP-registered) has noexecute. - Prepare, don't prescribe. Ship widget rows into
pendingTables; the model decides whether to call SQL, compute, or answer directly.
The only acceptable harness branching is lastMessage.role === "tool" dispatched by toolMsg.function (get_widget_data default, get_skill_content, execute_agent_tool).
strict: true, zero errors at all times. CI gates on this.- No
anyunless unavoidable, and justify with a comment. Preferunknown+ type guards. interfacefor object shapes,typefor unions/intersections.- Wire types live in
src/protocol/types.ts; MCP-result types insrc/mcp/results.ts; ContentItem helpers inmcp-server/src/lib/typed.ts.
- Every tool input is a Zod schema. Validate with
.parse()— neveras. - Use
.describe()on every field; the description is what the LLM sees. - The MCP schema layer (
src/mcp/schema.ts) handlesenum,anyOf,oneOf,null, nestedobject,array.items,nullable,const. Extend it rather than collapsing intoz.unknown().
// Auto-execute tool — local + cheap, or state-bound to in-process data
const t = tool({ description, inputSchema, execute: async (args) => { ... } });
// Round-trip tool: omit execute → loop stops, harness emits SSE
const rt = tool({ description, inputSchema });stopWhen includes one hasToolCall(name) per round-trip tool — get_widget_data, get_skill_content, every workspace bridge command name (when generativeUiEnabled), plus mcpToolsResult.stopCondition for external MCP tools, plus stepCountIs(15).
Consume result.fullStream and emit live as parts arrive (src/agent/loop.ts):
text-delta→ buffer; flush+messageChunkontext-end(whole-segment, sostripPlaceholderTagscan't be defeated by a tag split across deltas — token-level text streaming is intentionally deferred)tool-call→reasoningStepper call, carrying{ tool_name, input }(the eval trace runner reads this to attribute in-process calls)- drain
artifactQueueafter every part (SQL family pushes synchronously fromexecute; MCP results push fromprocessMcpResulton the next re-POST) — artifacts land right after the tool that produced them abort/error(or a thrown stream error) → ERRORreasoningStep+ return- after the stream drains,
await result.steps/result.totalUsage/result.textfor the round-trip dispatch (readslastStep/finalText)
Tools never yield SSE directly. The artifactQueue is the single ordered side-channel.
Some data must ride along with an MCP call without the LLM seeing it. Two capabilities use this path: execute_code (x-agentrita-conversation-id, x-agentrita-tables) and the document-RAG tools query_documents / list_documents (x-agentrita-conversation-id, x-agentrita-documents). Three rules:
- Inject into
parametersinsrc/agent/loop.tsright before yieldingexecuteAgentTool. - Read on the MCP side from the tool's args object.
- Any new internal-only key must start with
x-agentrita-—src/mcp/schema.tsstrips that prefix from the model-facing schema. Without the prefix the model will see and hallucinate the field.
execute_code ships only the delta: tablesShipped tracks names already in the sandbox, persisted as extra_state.compute_tables_shipped and invalidated per-name by setPendingTable (src/agent/pending-tables.ts) whenever a pendingTables entry is overwritten. Daytona sandbox recreation (idle auto-stop) is detected one call late via the silent sandbox_meta result item: id mismatch → tablesShipped cleared → next call full-ships. The one failed call in between is expected — the model sees the Python error and retries. Every round-trip emission must carry the continuation extra_state (buildContinuationExtraState in src/agent/loop.ts); an emission that drops those keys silently wipes the delta state mid-turn.
The SQL family lives in-process on the agent and reads pendingTables directly — no decoration, no wire payload.
- AsyncGenerators for SSE — every response path yields
SSEEventobjects, never writes directly. - LogTape: agent uses
["app", ...], MCP server uses["mcp", ...]. A single grep onconversationIdtraces a request across both processes. - No classes. Pure functions and interfaces only.
- Request-scoped state goes in tool factories:
makeSearchWidgetsTool(allWidgets),makeSqlTools({pendingTables, artifactQueue}),makeMcpTools(agentTools),makeRetryTracker(). Stateless tool factories (makeWidgetDataTool,makeGetSkillContentTool,makeWorkspaceTools) follow the same convention for consistency and testability. - MCP server tool files export
<name>Schema,<name>Description,<name>Handlerand register inmcp-server/src/server.ts. - Use the existing
singleShotLlm(src/lib/llm.ts) for any new/generate/*route — don't hand-roll a newgenerateTextwrapper.
X-Trace-Idis the conversation key. Workspace sets it per chat; the agent threads it asconversationIdintoexecute_codedecoration. Missing → loop suppressesexecute_codefor that turn (SQL family is in-process and unaffected). Any code that needs per-chat state should derive from this, not invent its own ID.- MCP
content[]arrives flattened. Workspace'suseMcpExecutor.tsJSON-stringifies the whole array into a single text item.processMcpResultrecurses into array elements — preserve that when adding new typed-result kinds. - The model must see tool outcomes. Artifacts ride the SSE side-channel, so
dispatchTypedinjects a delivery-ack line into the LLM message, andbuildMessagesrenders every earlierexecute_agent_toolresult viarenderToolResultForHistory(pure — no queue pushes, norememberRows); the last tool message stays withinjectFromReboot. When adding a typed-result kind, extend BOTHdispatchTypedandrenderToolResultForHistory, or the model goes amnesiac about that kind and re-runs finished work. COMPUTE_PERMANENTLY_UNAVAILABLEin any prior message disablesexecute_codefor the rest of the chat. Don't surface that string anywhere except the genuine "missing conversation id" path inexecute-code.ts.- Workspace bridge ops are round-trips, one per turn. The agent emits exactly ONE
copilotFunctionCallSSE per turn — the first bridge call of the step (the browser executes each emission and re-POSTs immediately, so a second emission in the same turn would fork the conversation into duplicate continuations). When the model fires several bridge calls in one step, the remainder are QUEUED inextra_state.pending_bridge_callsand the loop drains them one-per-re-POST (before re-running the model) — NOT re-issued by the model, which it does not reliably do after firing them up front (2026-06-11: 19update_widgetcalls, 1 executed, rest dropped, "I updated the widget" hallucinated). Mirror ofpending_widget_data_requests; rides the sameextra_stateecho (FEfunctionCallSchemakeeps it viaz.any()).buildMessagesrenders EARLIER bridge results into history (likeexecute_agent_tool) so the final turn knows every queued mutation landed. Command names go over the wire verbatim — no legacy renames (theupdate_widget→update_widget_in_dashboardremap is gone;round-trip.tsstill canonicalizes the old name when reading saved chat history). The browser runs the op viauseWorkspaceBridgeCommandHandler(shared with the WS companion bridge) and re-POSTs a{status, message, data}tool result. Because they round-trip, bridge emissions MUST carrybuildContinuationExtraStatelike every other round-trip. (The 16 ops are listed insrc/protocol/bridge-commands.ts.) - SQL family runs in-process. Tools have
executeand readpendingTablesfrom the factory closure.create_artifactpushes the artifact directly ontoartifactQueue(loop drains aftergenerateText). Don't try to shipx-agentrita-tablesfor these — that path isexecute_code-only now. - SSRM widgets (those with
metadata.schema) take inline SQL viainput_args.query. Don't try to pre-load their data intopendingTables; the round-trip handles it. - MCP factory filter.
src/mcp/factory.tsdrops user-supplied MCP wrappers whose name collides with an agent-owned tool (SQL family + native helpersenhance_prompt/_llm_think/create_table_from_text/create_html_artifact/create_app+ 16 bridge commands + bridge-mount extrasupdate_widget_layout/get_widget_data/get_skill_contentfromBRIDGE_MOUNT_EXTRA_NAMES). If you add a new agent-owned tool, add its name to the filter so Path-B duplicates don't double-register. - The prompt advertises only registered MCP tools.
buildSystemPrompttakesmcpToolEntries(the post-filtermakeMcpTools(...).entries) — never list rawrequest.toolsin the prompt. A prompt/registration mismatch makes the model call ghost tools (it sees a name+description with no schema, invents args, getsNoSuchToolError, and may then hallucinate success).
- Stateless / heavy / cold-start (web search, fetch, mermaid, sandbox-backed): write a new MCP tool in
mcp-server/src/tools/. ExportSchema/Description/Handler, register inserver.ts. Usetyped.tshelpers for artifacts/citations/tables. - State-bound (operates on
pendingTables,artifactQueue, or other request-scoped agent state): add an agent tool undersrc/agent/tools/with anexecutethat closes over the context. Match themake<Name>Tool(ctx)factory pattern. - New workspace bridge op: extend
src/protocol/bridge-commands.ts(Zod schema + description + name inWORKSPACE_BRIDGE_COMMANDS). The factory + loop dispatch picks it up generically. - Update README.md tool table when adding/removing tools.