Coach API reference
Coach API reference
All routes are under /api/v0/coach/ and require session authentication (enforced by the outer
authMiddleware on /api/v0). IDOR protection is enforced throughout: every resource lookup joins
through created_by = authUserId or entity_id = entityId, so a caller can only read and modify
their own resources.
Source: packages/api/src/routes/coach.ts.
Table of contents
- Conversations (new runtime)
- Planned actions
- Sessions (legacy runtime)
- Quests
- Goals
- Approvals
- Permissions
- Scheduled wake-ups
- User preferences
- Voice
- Backend routing
- SSE stream format
Conversations (new runtime)
The primary chat interface. Routes through getBackend().chat() — either LocalBackend (external
meshi-agent-runtime) or AgentBackend (platform-native LLM loop), depending on
MESHI_RUNTIME_BACKEND. Requires an existing entity; users who have not started onboarding receive
403.
POST /conversations
Create a new conversation.
Request body:
{ "title": "optional initial title" }title is optional (string); auto-titled after the first turn if empty.
Response 200:
{ "conversation": { "id": "uuid", "created_by": "auth_user_id", "title": "", "visibility": "private", "created_at": "...", "updated_at": "..." }}GET /conversations
List all conversations for the current user, newest first.
Response 200:
{ "conversations": [{ "id": "...", "title": "...", "created_at": "...", "updated_at": "..." }] }GET /conversations/:id
Get a conversation and its full message history.
Response 200:
{ "conversation": { "id": "...", "title": "...", "created_at": "..." }, "messages": [ { "id": "...", "conversation_id": "...", "role": "user | assistant", "content": "...", "parent_message_id": "uuid | null", "source_type": "user | agent", "response_object": null, "created_at": "..." } ]}response_object is non-null on assistant messages that were generated with tool calls — see
SSE stream format for the tool_events shape.
Response 404: conversation not found or not owned by caller.
PATCH /conversations/:id
Update the conversation title.
Request body:
{ "title": "New title" }title must be a string. Response 200: { "ok": true }.
PATCH /conversations/:cid/messages/:mid
Edit a message’s content. Used by the in-bubble edit affordance. Only messages in the caller’s own
conversations are editable. Original content is stashed in message_metadata.edit_history for
audit; edits do not branch the conversation (branching support is a future phase).
Request body:
{ "content": "corrected message text" }Response 200: the updated message row. Response 404: conversation or message not found /
not owned.
POST /conversations/:id/messages
Stream a chat turn. The primary message endpoint — this is what the web client calls for every user message.
Request body:
{ "content": "user message text (non-empty string, required)" }Flow:
- Validates conversation ownership (404 if not found or not yours).
- Resolves
entityIdfor the caller (403 if no entity — user must have started onboarding). - Loads prior message history.
- Persists the user message immediately (survives mid-stream reloads).
- Opens an
agent_runrow withstatus='running'. - Calls
getBackend().chat()with the full message history andCOACH_SYSTEM_PROMPT. - Tees the upstream SSE stream: passes bytes straight through to the browser AND accumulates content + tool events server-side.
- On
[DONE]: persists the assistant message (withtool_eventsinresponse_object), closes theagent_runrow, and fire-and-forgets an auto-title call if the conversation is untitled.
Response: 200 text/event-stream (SSE). See SSE stream format.
Error responses:
| Status | Meaning |
|---|---|
400 | content is missing or empty |
403 | No entity — complete onboarding first |
404 | Conversation not found or not yours |
502 | Backend returned an error; body contains upstream_status + upstream_body + user_message_id |
Planned actions
Planned actions are agent-proposed steps enqueued during a coach turn. Users can approve, cancel, or snooze them from the chat UI.
PATCH /planned-actions/:id
Transition a planned action’s state.
Request body:
{ "op": "approve | cancel | snooze", "minutes": 30 }minutes is required for snooze (positive integer, moves execute_after forward by that many
minutes).
States and transitions:
| Current state | approve | cancel | snooze |
|---|---|---|---|
pending | → approved | → cancelled | → pending (new execute_after) |
approved | 409 already | → cancelled | → pending |
executing | 409 in-flight | 409 in-flight | 409 in-flight |
completed, cancelled, failed | 409 terminal | 409 terminal | 409 terminal |
Authorization is enforced through the conversation: the planned action’s conversation_id must be
owned by the caller.
Response 200: the updated planned action in the same shape that MCP tool results use:
{ "planned_action": { "id": "...", "conversation_id": "...", "action_type": "...", "intent": "...", "reasoning": "...", "state": "approved", "priority": 0, "execute_after": "...", "parameters": {}, "user_explicitly_requested": true, "created_at": "..." }}Sessions (legacy runtime)
The legacy coach entry point — uses @meshi/coach directly, not getBackend(). Kept for backwards
compatibility while the conversations endpoint is fully adopted.
POST /session
Start or continue a coach session.
Request body:
{ "message": "user message (required)", "session_id": "optional existing session id", "conversation_id": "optional conversation id", "allow_onboarding": true}allow_onboarding: true enables conversational onboarding: if the user has no entity yet,
startOnboarding(db, authUserId, email, {}) is called automatically (no LinkedIn URL), creating an
entity in review/goals_review state. Without this flag, users without an entity receive 403.
Response: SSE stream from @meshi/coach.
GET /sessions
List recent sessions. Query param: limit (1–50, default 10).
Response 200: { "sessions": [...] }
GET /sessions/:id/actions
Get the why-path (action log) for a session.
Response 200: { "actions": [...] }. Returns 404 if the session belongs to another user.
Quests
Quests are structured tasks surfaced to the user by the platform or agent. The first quest created
for every user is the “Meet your coach” welcome quest, generated inline after
completeOnboarding().
GET /quests
List quests. Query params:
| Param | Type | Default | Description |
|---|---|---|---|
all | "true" | false | Include completed + dismissed quests |
limit | number | 20 | Max results (1–50) |
Response 200: { "quests": [...] }
POST /quests/:id/complete
Mark a quest as completed. Returns 404 if the quest is not found or not owned by the caller.
Response 200: the updated quest row.
POST /quests/:id/dismiss
Dismiss a quest (soft-remove from the active list). Returns 404 if not found or not owned.
Response 200: the updated quest row.
Goals
POST /goals/:id/confirm
Confirm a proposed goal, making it active for matchmaking. This is a user-only action; the agent
does not confirm goals on the user’s behalf — it uses create_goal and then surfaces the proposed
goal for the user to confirm explicitly.
Response 200: the updated goal. Response 400: error message from the service layer.
Response 404: goal not found or not owned.
Approvals
The approval queue holds agent-proposed actions that require explicit user consent before execution.
GET /approvals
List pending approvals for the current user.
Response 200: { "approvals": [...] }
POST /approvals/:id/decide
Decide on a pending approval.
Request body:
{ "decision": "approved | rejected | snoozed", "note": "optional note (max 1000 chars)"}When decision = "approved", fires a coach/approval.decided Inngest event to trigger execution
(fire-and-forget — the approval is recorded even if the event emit fails). Returns 404 if the item
is not found or already decided.
Response 200: the updated approval item.
Permissions
Permission grants control what surfaces and scopes the agent is allowed to act on autonomously without per-action approval.
Valid surfaces: coaching, network, email, nudge
Valid scopes: read, write, full, coaching
GET /permissions
List active permission grants for the current user.
Response 200: { "permissions": [...] }
POST /permissions
Grant a permission.
Request body:
{ "surface": "network", "scope": "read" }Both fields are required strings. Returns 400 if surface or scope is not in the allowed set.
Response 201: the created grant.
DELETE /permissions/:surface/:scope
Revoke a permission grant. Returns 404 if not found or already revoked.
Response 200: { "revoked": true }
Scheduled wake-ups
GET /scheduled
List pending scheduled wake-ups for the current user.
Response 200: { "scheduled": [...] }
User preferences
GET /preferences
Get the current user’s coach preferences.
Response 200:
{ "preferences": { "timezone": "America/Los_Angeles", "quiet_hours_start": 22, "quiet_hours_end": 8, "notification_email": true }}POST /preferences
Update coach preferences. All fields are optional — pass only what you want to change.
Request body:
{ "timezone": "America/Los_Angeles", "quiet_hours_start": 22, "quiet_hours_end": 8, "notification_email": true}| Field | Type | Validation |
|---|---|---|
timezone | string | Must be a valid IANA timezone identifier (validated via Intl.DateTimeFormat) |
quiet_hours_start | number | 0–23 |
quiet_hours_end | number | 0–23 |
notification_email | boolean | — |
Response 200: { "preferences": { ... } }
Voice
GET /voice
WebSocket endpoint for voice sessions. Requires a WebSocket upgrade (Upgrade: websocket header).
Returns 426 if the header is missing.
Delegates to handleVoiceWebSocket() from @meshi/coach. The WebSocket receives the same userId
/ entityId context as the REST endpoints.
Backend routing
The POST /conversations/:id/messages endpoint calls getBackend() which returns one of:
MESHI_RUNTIME_BACKEND | Backend | Notes |
|---|---|---|
local (default) | LocalBackend | HTTP roundtrip to meshi-agent-runtime via Fly internal DNS. Requires MESHI_RUNTIME_URL + MESHI_RUNTIME_API_KEY. |
agent | AgentBackend | In-process LLM loop; 20 tools including onboarding. Requires AGENT_API_KEY (or CEREBRAS_API_KEY / OPENROUTER_API_KEY). |
fly | FlyBackend | Decommissioned. Do not use. |
See agent-runtime-integration.md for the full backend mode matrix.
SSE stream format
The POST /conversations/:id/messages response is text/event-stream. The stream carries several
event types:
meshi-meta events
Emitted twice: once at stream start (before any content), once at stream end.
event: meshi-metadata: {"user_message_id": "uuid", "conversation_id": "uuid"}
event: meshi-metadata: {"assistant_message_id": "uuid"}The first meshi-meta lets the client swap its optimistic user message bubble for the real
persisted ID. The second lets the client record the assistant message ID once it is persisted.
Content delta events (OpenAI shape)
Standard OpenAI streaming format, passed through verbatim from the backend:
data: {"choices":[{"delta":{"content":"Hello"}}]}data: {"choices":[{"delta":{"content":", world"}}]}data: [DONE]Tool events (runtime-native, passed through)
When the backend is meshi-agent-runtime (local mode), the runtime may emit custom SSE events for
tool-call chrome. These are passed through to the browser verbatim:
event: meshi-tool-resultdata: {...}
event: meshi-pre-tool-reasoningdata: {...}Persisted response_object.tool_events
The platform accumulates tool-call events from the stream and stores them on the assistant message’s
response_object column so reloads can rebuild the tool-call chrome without replaying the runtime.
type ToolEvent = | { type: "call"; index: number; id?: string; name?: string; arguments_json: string } | { type: "result"; tool_call_id: string; content: string } | { type: "pre_reasoning"; content: string };The array is chronological. A typical multi-round turn:
[pre_reasoning, call, result, pre_reasoning, call, result] then final prose in message.content.
Present only on role='assistant' messages. Pre-migration messages (and messages from turns with no
tool calls) have response_object = null — consumers must handle both.
agent_run observability
Every turn opens an agent_run row (migration 083) before calling the backend and closes it on
stream completion:
| Column | Description |
|---|---|
conversation_id | The conversation this turn belongs to |
message_id | Backfilled with the assistant message id on success |
model | Model name as reported by the runtime (may be null if backend doesn’t emit it) |
tool_call_count | Number of distinct tool calls observed across all rounds |
status | running on open → succeeded / failed / aborted on close (timed_out is in the schema but never set by this handler) |
error_message | Set on failed — includes "no content received from runtime" when the upstream closed cleanly with no text |
finished_at | Timestamp when stream closed |
Empty-response path: If the upstream SSE closes cleanly but no content delta was ever received
(assistantContent.length === 0), no assistant message row is created, agent_run.message_id stays
null, and status is set to failed with error_message = "no content received from runtime".
The second meshi-meta event (assistant_message_id) is not emitted in this path. Clients must
handle the absence of the closing meshi-meta event.
Abandoned streams (client disconnect before content arrives) leave rows in running state — these
can be identified by finished_at IS NULL after a reasonable timeout.