Skip to main content
Loom Core docs

Tracking

Mobile Companion API v1 (Contract Freeze)

This document defines the API contract for the Loom Companion iPhone/iPad app.

Status: v1 additive contract freeze (updated 2026-04-16). Initial M0/M1 endpoints plus read-only parity wave endpoints are implemented under internal/hud/domain/mobile/.

Tracking

Goals

  • Provide a stable mobile-facing contract without coupling the app to internal HUD handler shapes.
  • Support both connectivity modes:
    • LAN mode (trusted/local network)
    • Gateway mode (remote/zero-trust)
  • Keep v1 scope focused on monitoring + session lifecycle control.
  • Preserve read-only posture for parity-wave features (tasks/workflows/presence/memory/stream/topology/graph/reasoning).

Versioning

  • API prefix: /api/mobile/v1
  • Compatibility target: additive-only changes within v1 when possible.
  • Breaking changes require v2 or explicit migration notes.

Connectivity Modes

The same API contract is used in both modes.

ModeTypical endpointPrimary use case
LANhttps://<lan-host>:<port>/api/mobile/v1Same network, low-latency ops
Gatewayhttps://mcp.flexinfer.ai/api/mobile/v1Off-network remote operations

Notes:

  • Client profile selects mode.
  • Gateway mode must not assume LAN trust.
  • Unified gateway path split on mcp.flexinfer.ai:
    • MCP hub: /ws, /hosts, /health, /ready
    • Mobile API: /api/mobile/v1/*

Auth Model (Contract-Level)

  • Authorization: Bearer <token> required for protected endpoints.
  • See Mobile Companion Auth Bootstrap for the full auth bootstrap decision, flow diagrams, and LAN/gateway comparison.
  • Bootstrap decision (MBL-1):
    • v1 default: direct native OAuth authorization code + PKCE in an external browser/system auth session.
    • v1 fallback: device-code pairing for profiles where direct browser-mediated auth is not practical.
    • Fallback path is explicit and profile/policy selected; do not silently downgrade from OAuth+PKCE.
  • Client UX contract: connection profile setup must display the active bootstrap mode and clearly indicate when fallback mode is being used.
  • Token claims must include:
    • actor identity (sub or equivalent),
    • role/scope,
    • device/session identifier.
  • Anonymous access is limited to optional health/probe routes only.

mobile_operator Authorization Matrix (Contract View)

This section freezes the v1 endpoint allowlist for the mobile_operator role. Routes are classified as core_operator, advanced_read, or guarded_mutation; any mutation requires its named scope and remains denied by default without it.

EndpointMethodCategoryAccessScope
/api/mobile/v1/pingGETcore_operatorallowmobile:read
/api/mobile/v1/dashboardGETcore_operatorallowmobile:read
/api/mobile/v1/control-planeGETcore_operatorallowmobile:read
/api/mobile/v1/sessionsGETcore_operatorallowmobile:read
/api/mobile/v1/sessions/treeGETcore_operatorallowmobile:read
/api/mobile/v1/sessions/{session_id}GETcore_operatorallowmobile:read
/api/mobile/v1/sessions/{session_id}/eventsGETadvanced_readallowmobile:read
/api/mobile/v1/sessions/{session_id}/traceGETadvanced_readallowmobile:read
/api/mobile/v1/sessions/{session_id}/activityGETcore_operatorallowmobile:read
/api/mobile/v1/tasksGETcore_operatorallowmobile:read
/api/mobile/v1/workflowsGETcore_operatorallowmobile:read
/api/mobile/v1/workflows/{workflow_id}GETcore_operatorallowmobile:read
/api/mobile/v1/presenceGETcore_operatorallowmobile:read
/api/mobile/v1/agentsGETcore_operatorallowmobile:read
/api/mobile/v1/namespacesGETcore_operatorallowmobile:read
/api/mobile/v1/memory/statsGETadvanced_readallowmobile:read
/api/mobile/v1/memory/itemsGETadvanced_readallowmobile:read
/api/mobile/v1/streamGETadvanced_readallowmobile:read
/api/mobile/v1/topologyGETadvanced_readallowmobile:read
/api/mobile/v1/graph/statsGETadvanced_readallowmobile:read
/api/mobile/v1/graph/entitiesGETadvanced_readallowmobile:read
/api/mobile/v1/graph/pathGETadvanced_readallowmobile:read
/api/mobile/v1/reasoning/chainsGETadvanced_readallowmobile:read
/api/mobile/v1/reasoning/chains/{chain_id}GETadvanced_readallowmobile:read
/api/mobile/v1/events/streamGETadvanced_readallowmobile:read
/api/mobile/v1/auditGETadvanced_readallowmobile:read
/api/mobile/v1/alerts/policyGETcore_operatorallowmobile:read
/api/mobile/v1/sandboxGETcore_operatorallowmobile:read
/api/mobile/v1/telemetry/recoveryGETcore_operatorallowmobile:read
/api/mobile/v1/pipelinesGETcore_operatorallowmobile:read
/api/mobile/v1/handoffsGETcore_operatorallowmobile:read
/api/mobile/v1/handoffs/{handoff_id}/acceptPOSTguarded_mutationallowmobile:read
/api/mobile/v1/handoffs/{handoff_id}/rejectPOSTguarded_mutationallowmobile:read
/api/mobile/v1/agent/spawnsGETcore_operatorallowmobile:read
/api/mobile/v1/agent/spawn/configGETcore_operatorallowmobile:read
/api/mobile/v1/agent/spawn/{spawn_id}GETcore_operatorallowmobile:read
/api/mobile/v1/agent/spawn/{spawn_id}/streamGETadvanced_readallowmobile:read
/api/mobile/v1/agent/spawn/{spawn_id}/telemetryGETadvanced_readallowmobile:read
/api/mobile/v1/agent/spawn/{spawn_id}/telemetry/toolsGETadvanced_readallowmobile:read
/api/mobile/v1/agent/spawn/{spawn_id}/telemetry/filesGETadvanced_readallowmobile:read
/api/mobile/v1/agent/spawn/{spawn_id}/telemetry/errorsGETadvanced_readallowmobile:read
/api/mobile/v1/agent/spawn/{spawn_id}/traceGETadvanced_readallowmobile:read
/api/mobile/v1/sessionsPOSTguarded_mutationallowmobile:session:create
/api/mobile/v1/sessions/{session_id}/endPOSTguarded_mutationallowmobile:session:end
/api/mobile/v1/push/registerPOSTguarded_mutationallow (feature-flagged)mobile:push
/api/mobile/v1/push/unregisterPOSTguarded_mutationallow (feature-flagged)mobile:push
/api/mobile/v1/telemetry/recoveryPOSTguarded_mutationallow (scope-gated, off by default)mobile:telemetry
/api/mobile/v1/admin/revokePOSTguarded_mutationallow (admin-gated)admin token + mobile:read
/api/mobile/v1/sandbox/startPOSTguarded_mutationallowmobile:agent:spawn
/api/mobile/v1/sandbox/stopPOSTguarded_mutationallowmobile:agent:spawn
/api/mobile/v1/workflows/{workflow_id}/approvePOSTguarded_mutationallowmobile:agent:spawn
/api/mobile/v1/workflows/{workflow_id}/rejectPOSTguarded_mutationallowmobile:agent:spawn
/api/mobile/v1/agent/spawnPOSTguarded_mutationallowmobile:agent:spawn
/api/mobile/v1/agent/spawn/{spawn_id}/stopPOSTguarded_mutationallowmobile:agent:spawn
/api/mobile/v1/agent/spawn/{spawn_id}/messagePOSTguarded_mutationallowmobile:agent:spawn
/api/mobile/v1/agent/spawn/{spawn_id}/interruptPOSTguarded_mutationallowmobile:agent:spawn
/api/mobile/v1/agents/{agent_id}/session/endPOSTN/Adeny in v1N/A
/api/agent/* direct mutation routesPOSTN/Adeny for mobile tokensN/A

Mode policy:

  • LAN and gateway use the same endpoint permissions.
  • Gateway requires TLS and strict cert validation.
  • Deny by default when required scope is missing.

Response Contract

For mobile endpoints, use a consistent envelope:

{
  "ok": true,
  "data": {},
  "meta": {
    "request_id": "req_...",
    "timestamp": "2026-02-19T19:00:00Z"
  }
}

Errors:

{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "invalid token"
  },
  "meta": {
    "request_id": "req_...",
    "timestamp": "2026-02-19T19:00:00Z"
  }
}

Endpoints (v1 Frozen)

All endpoints return the standard envelope. The data field for each is defined below.

Wave 1 Read-Only Parity Additions (2026-02-25)

The following additive endpoints are now part of /api/mobile/v1:

EndpointPurposeQuery ParamsResponse Shape
GET /tasksTask list + status countsstatus, agent_id, session_id, limit, search{tasks, counts}
GET /workflowsWorkflow summariesstatus, agent_id, limit{workflows, pending_approvals}
GET /workflows/{workflow_id}Workflow detail + timeline eventsnone{workflow, events}
GET /presencePresence + claim/worktree snapshotstatus, agent_id, limit{agents, claims, worktrees, summary}
GET /memory/statsMemory tier totalsnone{stats}
GET /memory/itemsMemory recall (read-only)tier, query, limit{items, tier}
GET /streamContext stream entriestypes, agent_id, session_id, limit{entries}
GET /topologyAgent topology graphnone{nodes, edges, clusters, updated_at}
GET /graph/statsGraph counts by typenone{stats}
GET /graph/entitiesEntity search/listtype, q, limit{entities}
GET /graph/pathPath between two entitiessource_id, target_id, max_depth{path}
GET /reasoning/chainsReasoning chain summariesstatus, limit{chains}
GET /reasoning/chains/{chain_id}Reasoning chain detailnone{chain}
GET /sessions/treeServer-computed session hierarchystatus{roots, orphans, summary}
GET /sessions/{session_id}/activityTask/pipeline pressure for a sessionnone{session_id, tasks, pipelines, task_count, pipeline_count}
GET /agentsUnified agent rosterstatus, type, limit{agents, summary}
GET /namespacesNamespace rollupnone{namespaces}
GET /pipelinesActive and recent pipelinesnone{pipelines, recent_pipelines, summary, available}
GET /handoffsHandoff inboxlimit{handoffs}
POST /handoffs/{handoff_id}/acceptAccept a pending handoff (body: session_id or target_agent_id, optional import_entries)none{status:"accepted", handoff_id, session_id, result}
POST /handoffs/{handoff_id}/rejectReject a pending handoff (body: optional reason)none{status:"rejected", handoff_id, result}
GET /agent/spawnsSpawn rosternone{spawns}
GET /agent/spawn/configSpawn project/type confignone{projects, agent_types}
GET /agent/spawn/{spawn_id}Spawn detailnonespawn status object
GET /agent/spawn/{spawn_id}/streamSpawn event streamnoneSSE
GET /agent/spawn/{spawn_id}/telemetrySpawn telemetry summarynonetelemetry object
GET /agent/spawn/{spawn_id}/telemetry/toolsSpawn tool-call telemetry pageoffset, limitpage object
GET /agent/spawn/{spawn_id}/telemetry/filesSpawn file telemetry pageoffset, limitpage object
GET /agent/spawn/{spawn_id}/telemetry/errorsSpawn error telemetry pageoffset, limitpage object
GET /agent/spawn/{spawn_id}/traceSpawn tracenonetrace object

Contract rules for these additions:

  • Additive-only fields (no breaking shape changes under v1).
  • Explicit array defaults ([] instead of null).
  • Status fields normalize unknown values to "unknown" where applicable.
  • All endpoints remain scope-gated by mobile:read and are denied outside /api/mobile/v1/*.

GET /api/mobile/v1/ping

Connectivity probe. Scope: mobile:read.

Response data:

{
  "pong": true
}

Source: internal/hud/domain/mobile/handler_dashboard.go


GET /api/mobile/v1/dashboard

Mobile dashboard aggregate for quick app open. Scope: mobile:read.

Attention lanes preserve their original v1 fields and may include additive routing metadata so clients can dispatch directly to the right surface:

{
  "type": "blocked_task",
  "id": "task-123",
  "label": "Blocked task",
  "route": "work",
  "scope": "loom-core/mobile",
  "summary": "2 blocked tasks",
  "severity": "warning",
  "target_kind": "task_filter",
  "target_id": "",
  "deep_link": "loom://tasks?status=blocked&session=sess_abc123",
  "filter": {
    "status": "blocked",
    "session_id": "sess_abc123"
  },
  "recommended_action": "Review blocked tasks",
  "freshness": {
    "source": "fleet_snapshot",
    "updated_at": "2026-05-11T14:00:00Z"
  }
}

Supported target_kind values are agent, session, task_filter, workflow, spawn, handoff, connection, alert, pipeline, and namespace. If these fields are absent, clients must fall back to route.

Response data:

{
  "daemon_running": true,
  "server_count": 5,
  "active_sessions": 2,
  "active_agents": 3,
  "idle_agents": 1,
  "offline_agents": 0,
  "updated_at": "2026-02-23T12:00:00Z",
  "health": {
    "total_servers": 5,
    "healthy_servers": 4,
    "degraded_servers": 1,
    "down_servers": 0,
    "idle_servers": 0
  },
  "recent_timeline": [
    {
      "timestamp": "2026-02-23T11:59:00Z",
      "event_type": "agent.session.start",
      "agent_id": "claude-code",
      "agent_type": "claude-code",
      "data": {}
    }
  ]
}
FieldTypeDescription
daemon_runningboolWhether loomd is running
server_countintTotal registered MCP servers
active_sessionsintSessions with status active
active_agentsintAgents with presence status active
idle_agentsintAgents with presence status idle
offline_agentsintAgents with presence status offline
updated_atstring (RFC3339)Fleet snapshot timestamp
healthobjectServer health summary (see HealthSummary)
health.total_serversintTotal servers monitored
health.healthy_serversintServers passing health checks
health.degraded_serversintServers with intermittent failures
health.down_serversintServers failing health checks
health.idle_serversintServers with no recent activity
recent_timelinearrayLast 10 TimelineEntry objects

TimelineEntry schema:

FieldTypeDescription
timestampstring (RFC3339)Event time
event_typestringEvent type identifier
agent_idstringAgent that generated the event (omitted if N/A)
agent_typestringAgent type (omitted if N/A)
dataobjectEvent-specific payload

Source: internal/hud/domain/mobile/handler_dashboard.go, internal/hud/monitor/fleet.go, internal/hud/monitor/health.go, internal/hud/eventlog.go


GET /api/mobile/v1/sessions

List all sessions. Scope: mobile:read.

Response data:

{
  "sessions": [
    {
      "id": "sess_abc123",
      "agent_id": "claude-code",
      "namespace": "loom-core/main",
      "status": "active",
      "description": "Working on mobile API",
      "started_at": "2026-02-23T10:00:00Z",
      "entry_count": 42,
      "total_tokens": 8500
    }
  ]
}

SessionInfo schema:

FieldTypeDescription
idstringSession identifier
agent_idstringOwning agent
namespacestringSession namespace
statusstringactive or ended
descriptionstringHuman-readable session description
started_atstring (RFC3339)Session start time
ended_atstring (RFC3339)Session end time (omitted if active)
entry_countintNumber of context entries
total_tokensintEstimated token usage
parent_session_idstringDirect parent session when this row is child/subagent work
root_session_idstringRoot session for the hierarchy; roots normalize to their own id

Source: internal/hud/domain/mobile/handler_sessions.go, internal/hud/bridge/agent.go


GET /api/mobile/v1/sessions/{session_id}

Single session detail. Scope: mobile:read.

Response data:

{
  "session": {
    "id": "sess_abc123",
    "agent_id": "claude-code",
    "namespace": "loom-core/main",
    "status": "active",
    "description": "Working on mobile API",
    "started_at": "2026-02-23T10:00:00Z",
    "entry_count": 42,
    "total_tokens": 8500
  }
}

Returns a single SessionInfo (same schema as above) under data.session.

Error cases:

  • 400 bad_request — missing session_id
  • 404 not_found — session not found

Source: internal/hud/domain/mobile/handler_sessions.go

GET /api/mobile/v1/sessions/tree

Server-computed session hierarchy. Scope: mobile:read.

Query:

  • status: omitted defaults to active sessions; all returns all known statuses; comma-separated values filter by status.

Response data:

{
  "roots": [
    {
      "session": {},
      "depth": 0,
      "child_count": 2,
      "active_child_count": 1,
      "children": []
    }
  ],
  "orphans": [],
  "summary": {
    "root_count": 3,
    "active_sessions": 5,
    "orphan_sessions": 1,
    "updated_at": "2026-05-11T14:00:00Z"
  }
}

Root sessions with no explicit root_session_id normalize their root to their own id. Sessions whose parent_session_id is missing from the result are returned under orphans so clients can render them distinctly.

Source: internal/hud/domain/mobile/handler_sessions.go


GET /api/mobile/v1/sessions/{session_id}/events

Session-scoped event feed. Scope: mobile:read.

Query parameters:

ParamTypeDefaultMaxDescription
limitint100500Maximum events to return

Response data:

{
  "session_id": "sess_abc123",
  "events": [
    {
      "timestamp": "2026-02-23T11:00:00Z",
      "event_type": "agent.session.start",
      "agent_id": "claude-code",
      "agent_type": "claude-code",
      "data": {"session_id": "sess_abc123"}
    }
  ]
}
FieldTypeDescription
session_idstringEcho of requested session ID
eventsarrayTimelineEntry objects matching this session

Error cases:

  • 400 bad_request — missing session_id

Source: internal/hud/domain/mobile/handler_sessions.go


GET /api/mobile/v1/sessions/{session_id}/trace

Unified session trace. Scope: mobile:read.

This endpoint mirrors the desktop HUD session trace drawer for companion clients. It composes cached session metadata, session-scoped lifecycle events, context entries, and daemon audit/tool traces. If one source fails, for example because agent-context returns transport closed, the response still returns 200 with the remaining sources and a source-scoped item in data.errors.

Query parameters:

ParamTypeDefaultMaxDescription
limitint100500Maximum entries/events/traces per source
agent_idstring--Optional fallback filter when session metadata is unavailable

Response data:

{
  "session_id": "sess_abc123",
  "agent_id": "claude-code",
  "session": {"id": "sess_abc123", "agent_id": "claude-code", "status": "active"},
  "entries": [],
  "events": [],
  "traces": [
    {
      "timestamp": "2026-04-16T12:00:00Z",
      "agent_id": "claude-code",
      "server": "agent_context",
      "tool": "agent_context_search",
      "status": "error",
      "error": "transport closed",
      "duration_ms": 12
    }
  ],
  "trace_enabled": true,
  "trace_path": "/tmp/loom-audit.jsonl",
  "errors": [
    {"source": "context_entries", "message": "transport closed"}
  ],
  "retrieved_at": "2026-04-16T12:00:01Z"
}

Source: internal/hud/domain/mobile/handler_sessions.go, internal/hud/app_routes_operations.go


GET /api/mobile/v1/sessions/{session_id}/activity

Session task and pipeline pressure summary. Scope: mobile:read.

Response data:

{
  "session_id": "sess_abc123",
  "tasks": [
    {
      "id": "task-1",
      "title": "Fix mobile route",
      "status": "blocked",
      "priority": "high",
      "tags": ["mobile"],
      "workflow_id": "wf-1",
      "pipeline_id": 42,
      "created_at": "2026-05-11T14:00:00Z",
      "updated_at": "2026-05-11T14:01:00Z"
    }
  ],
  "pipelines": [
    {
      "id": 42,
      "project": "services/loom-core",
      "ref": "codex/mobile",
      "status": "failed",
      "current_stage": "test",
      "failed_job_count": 1,
      "web_url": "https://gitlab.example/pipelines/42"
    }
  ],
  "task_count": 1,
  "pipeline_count": 1
}

Source: internal/hud/domain/mobile/handler_agents.go


POST /api/mobile/v1/sessions

Create/start a new session. Scope: mobile:session:create.

Request body:

{
  "agent_id": "codex",
  "namespace": "loom-core/mobile",
  "description": "Investigate issue #123",
  "auto_recall": true
}
FieldTypeRequiredDescription
agent_idstringyesAgent to create the session for
namespacestringnoSession namespace
descriptionstringnoHuman-readable description
auto_recallboolnoAuto-recall previous context

Response: Delegates to internal handleAgentSessionStart handler. Returns the session envelope from the agent-context bridge.

Audit: Logs session_create with agent_id and namespace targets.

Source: internal/hud/domain/mobile/handler_sessions.go


POST /api/mobile/v1/sessions/{session_id}/end

End an active session. Scope: mobile:session:end.

Request body:

{
  "summarize": true
}
FieldTypeRequiredDescription
summarizeboolnoGenerate summary on end

Response: Delegates to internal handleAgentSessionEnd handler. Returns the session-end result from the agent-context bridge.

Error cases:

  • 400 bad_request — missing session_id or invalid body

Audit: Logs session_end with session_id and summarize targets.

Source: internal/hud/domain/mobile/handler_sessions.go


GET /api/mobile/v1/events/stream

SSE endpoint for mobile realtime feed. Scope: mobile:read.

Delegates to the existing /api/events SSE handler after auth validation.

Event allowlist in v1:

  • hud.fleet
  • hud.health
  • hud.pipeline
  • hud.workflows
  • hud.stream
  • agent.session.start
  • agent.session.end
  • agent.session.reaped
  • agent.session.stats.updated
  • agent.heartbeat

Source: internal/hud/domain/mobile/handler_ops.go


GET /api/mobile/v1/alerts/policy

Canonical event-to-severity-interruption-action matrix. Scope: mobile:read.

Mobile clients use this to synchronize notification behavior with the server-defined policy. The matrix defines how each SSE event type maps to severity, iOS interruption level, and allowed quick-actions.

Response data:

{
  "version": "v1",
  "policy": [
    {
      "event_type": "hud.health",
      "severity": "critical",
      "interruption_level": "time_sensitive",
      "title": "Server Down",
      "allowed_actions": ["view_dashboard", "acknowledge"],
      "conditional": true
    }
  ]
}

Policy entry schema:

FieldTypeDescription
event_typestringSSE event type this rule applies to
severitystringinfo, warning, or critical
interruption_levelstringpassive, active, time_sensitive, or critical
titlestringDisplay title for the alert
allowed_actionsarraySafe actions: view_session, view_dashboard, acknowledge
conditionalboolWhether severity depends on event payload (e.g., health events)

Interruption level semantics (maps to iOS UNNotificationInterruptionLevel):

LevelBehaviorUse case
passiveSilent; added to list without sound/bannerInfo-level events (session start/end, approvals)
activeDefault notification (sound + banner)Warnings requiring attention (reaped, degraded)
time_sensitiveBreaks through Focus/DNDCritical operational events (server down)
criticalReserved; not used in v1Emergency alerts (future)

Action constraints: All actions are read-only navigation operations. No mutation actions are permitted from alert quick-actions to maintain v1 scope discipline.

Source: internal/hud/domain/mobile/handler_ops.go, internal/hud/domain/mobile/helpers.go


POST /api/mobile/v1/push/register

Register a device push token for APNs or FCM notifications. Scope: mobile:push. Gated by --mobile-push-enabled feature flag (returns 404 when disabled).

Request body:

{
  "token": "device-push-token-hex-string",
  "platform": "apns"
}
FieldTypeRequiredDescription
tokenstringyesDevice push token from APNs or FCM
platformstringyes"apns" or "fcm"

Response data:

{
  "registered": true,
  "registration_id": "reg_a1b2c3d4"
}

Error cases:

  • 400 bad_request — missing/empty token, missing/invalid platform (must be apns or fcm)
  • 401 unauthorized — invalid bearer token
  • 403 forbidden — missing mobile:push scope
  • 404 not_found — push notifications not enabled (feature flag off)

Behavior: Re-registering the same token updates the last_used timestamp and device ID association. Tokens are stored in-memory for v1; persistent storage is planned for M5.

Source: internal/hud/domain/mobile/handler_push.go


POST /api/mobile/v1/push/unregister

Remove a device push token. Scope: mobile:push. Gated by --mobile-push-enabled feature flag.

Request body:

{
  "token": "device-push-token-hex-string"
}

Response data:

{
  "removed": true
}

The removed field is true if the token existed and was removed, false if the token was not found.

Error cases:

  • 400 bad_request — missing/empty token
  • 401 unauthorized — invalid bearer token
  • 403 forbidden — missing mobile:push scope
  • 404 not_found — push notifications not enabled (feature flag off)

Source: internal/hud/domain/mobile/handler_push.go


Push Retry and Payload Policy (MBL-7)

The push notification infrastructure enforces the following contracts:

Retry classification by HTTP status:

StatusActionReason
2xxNo retrySuccess
400No retryBad request (fix payload)
401/403No retryAuth/provisioning error
404Invalidate tokenDevice not registered
410Invalidate tokenToken expired/unregistered
429Retry after delayRate limited (honor Retry-After or 30s default)
5xxRetry with backoffServer error

Backoff policy: Exponential (2^n * 1s base, capped at 5m, max 5 retries).

Payload guardrails: APNs and FCM payloads are validated against 4096-byte limits. Oversized payloads are truncated at the body field with UTF-8-safe "..." suffix. Truncation never breaks multi-byte characters.

Token lifecycle: Invalid tokens (404/410 from APNs) are automatically removed from the device token store. Stale tokens are pruned by a background reaper every hour using a 30-day idle cutoff (via CleanupStale).

Source: internal/hud/mobile_push.go (ClassifyPushResponse, PushBackoffConfig, PushPayload.ValidateAndTruncate, DeviceTokenStore)


POST /api/mobile/v1/admin/revoke

Revoke a mobile operator token at runtime. Protected by admin token (X-Admin-Token header), not by mobile bearer auth.

Request headers:

  • X-Admin-Token: <admin_token> (required)

Request body:

{
  "token": "<mobile_token_to_revoke>"
}

Response data:

{
  "revoked": true
}

Error cases:

  • 400 bad_request — missing token field
  • 401 unauthorized — invalid admin token
  • 403 forbidden — admin token not configured

Audit: Logs token_revoke action.

Source: internal/hud/domain/mobile/handler_push.go


Recovery SLO Telemetry (MBL-5)

Cross-surface publishing of the in-app SSE disconnect-to-recovered SLO telemetry (ConnectionHealthMonitor, .loom/137) so the HUD can show fleet recovery health. All three slices are complete: backend ingestion + aggregation (slice 1, .loom/138), the iOS uploader (slice 2, .loom/139), and the HUD recovery-SLO tile (slice 3, .loom/140).

POST /api/mobile/v1/telemetry/recovery

Ingest one device's rolling window of disconnect-to-recovered durations. Scope: mobile:telemetry. Off by default — operators must add mobile:telemetry to HUD_MOBILE_OPERATOR_SCOPES to enable ingestion (mirrors mobile:agent:spawn). Keyed by the X-Device-ID header; rate-limited in the mutation class.

Request body:

{
  "samples": [5.2, 8.1, 22.3],
  "slo_target_seconds": 30
}
  • samples (required): recovery durations in seconds; each must be positive and finite. Non-finite/≤0 values are dropped; the window is truncated to the most recent 50 (matching the in-app maxRecoverySamples).
  • slo_target_seconds (optional): the device's p95 SLO target. Defaults to 30 (one poll-fallback cycle); clamped to (0, 3600].

Response data:

{
  "accepted": true,
  "device": {
    "device_id": "device-123",
    "sample_count": 3,
    "mean_seconds": 11.87,
    "p95_seconds": 22.3,
    "slo_target_seconds": 30,
    "meets_slo": true,
    "updated_at": "2026-06-08T12:00:00Z"
  }
}

Per-device p95_seconds uses the same nearest-rank formula as the iOS client (rank = ceil(0.95·n), index = clamp(rank-1)) so the published figure matches what operators see in-app. Re-ingesting from the same X-Device-ID replaces that device's snapshot.

Error cases:

  • 400 bad_request — invalid JSON, empty samples, or no positive finite sample
  • 400 missing_device_idX-Device-ID header absent
  • 401 unauthorized — invalid bearer token
  • 403 forbidden — missing mobile:telemetry scope
  • 429 rate_limited — mutation-class rate limit exceeded

iOS companion client (slice 2, .loom/139): the app's RecoveryTelemetryUploader posts its rolling ConnectionHealthMonitor.recoverySampleSeconds window on each transient-outage recovery. Resending the full window is idempotent (the backend replaces the device snapshot). The uploader dedups an unchanged window, stops permanently after a 403 (scope off by default), and treats 429/other errors as transient — keeping the write ingress disciplined while the scope remains unenabled.

GET /api/mobile/v1/telemetry/recovery

Fleet recovery-SLO rollup pooled across all reporting devices. Scope: mobile:read. Returns zeros with meets_slo: true when no device has reported yet.

Response data:

{
  "device_count": 2,
  "total_samples": 6,
  "fleet_mean_seconds": 7.5,
  "fleet_p95_seconds": 10,
  "slo_target_seconds": 30,
  "devices_meeting_slo": 2,
  "meets_slo": true,
  "devices": [
    {"device_id": "dev-a", "sample_count": 3, "mean_seconds": 6, "p95_seconds": 7, "slo_target_seconds": 30, "meets_slo": true, "updated_at": "2026-06-08T12:00:00Z"}
  ],
  "updated_at": "2026-06-08T12:00:01Z"
}

fleet_mean_seconds / fleet_p95_seconds are computed over the pooled samples of all devices (a true nearest-rank percentile of the combined window), not an average of per-device summaries. meets_slo is fleet_p95_seconds ≤ slo_target_seconds.

Audit: Ingestion logs telemetry_recovery_ingest with sample_count.

GET /api/telemetry/recovery (HUD operator web UI)

Same fleet rollup as the mobile read endpoint above, but served to the HUD operator web UI: same-origin and session-trusted — no bearer token or scope (the browser has no mobile token). The body is the raw RecoveryAggregate (no {ok,data,meta} envelope), matching the HUD-internal /api/* convention the Svelte stores consume. It reads the identical in-memory store, so the figure matches what the companion publishes and the mobile read returns. Consumed by the RecoverySLOCard tile on the HUD Overview panel (slice 3, .loom/140).

Source: internal/hud/domain/mobile/handler_telemetry.go, recovery_telemetry.go; HUD tile internal/hud/frontend/src/lib/components/RecoverySLOCard.svelte


Rate Limiting

Mobile API endpoints enforce per-actor, per-minute rate limits:

CategoryDefault limitConfig flagEnv var
Mutation (POST)10 req/min--mobile-rate-limit-mutationHUD_MOBILE_RATE_LIMIT_MUTATION
Read (GET)60 req/min--mobile-rate-limit-readHUD_MOBILE_RATE_LIMIT_READ
  • Actor is identified by remote IP address.
  • Set limit to 0 to disable rate limiting for that category.
  • Rate-limited requests receive 429 Too Many Requests with error code rate_limited.

Device Identity

Mobile clients may include an X-Device-ID header on all requests. When present, the device ID is included in audit log entries for mutation operations. Maximum length: 128 characters (truncated if longer).


Internal Mapping

Mobile endpoints delegate to existing internal surfaces:

Mobile endpointInternal handler
POST /sessionshandleAgentSessionStart
POST /sessions/{id}/endhandleAgentSessionEnd
GET /sessionsAgentBridge.Sessions()
GET /sessions/{id}AgentBridge.Sessions() + filter
GET /sessions/{id}/eventsEventLog.All() + filter
GET /events/streamhandleSSE
GET /dashboardFleetMonitor.Snapshot() + HealthMonitor.Summary() + EventLog.All()
GET /pingDirect response
GET /alerts/policymobileAlertPolicyMatrix()
POST /push/registerDeviceTokenStore.Register()
POST /push/unregisterDeviceTokenStore.Invalidate()

The mobile API layer normalizes these into stable DTOs with the mobileEnvelope wrapper.

Idempotency and Retry

  • POST /sessions should be idempotent for same active session context.
  • POST /sessions/{id}/end should be safe to retry; “already ended/not found” should not cause destructive side effects.
  • Mobile client can retry transient network failures with bounded backoff.

Pagination and Limits

  • Default per_page: 30
  • Max per_page: 100
  • Cursor-based pagination can be added later if list volume grows.

Audit Requirements

All mutation endpoints must record:

  • actor id
  • device id
  • source mode (lan or gateway)
  • endpoint/action
  • target ids (session/agent)
  • outcome + error (if any)

Sources

  • docs/MOBILE_COMPANION_AUTH_BOOTSTRAP.md — consolidated auth bootstrap decision, flow descriptions, and LAN/gateway comparison
  • internal/hud/domain/mobile/ — mobile v1 handlers, envelope types, auth helpers, and route registration
  • internal/hud/routes.go — top-level HUD route registration and mobile domain mounting
  • internal/hud/bridge/agent.go:30-41SessionInfo struct
  • internal/hud/monitor/fleet.go:18-63FleetSnapshot struct
  • internal/hud/monitor/health.go:98-105HealthSummary struct
  • internal/hud/eventlog.go:10-16TimelineEntry struct
  • docs/STREAMABLE_HTTP.md
  • .loom/20-product-spec.md