Files
Skald-Circle/docs/relay/payloads.md
T
2026-07-10 15:02:09 +01:00

17 KiB

E2E Payloads — Encrypted Content Schemas

This file defines the plaintext that is encrypted (AES-256-GCM, crypto.md §6) and transported in the ciphertext field of the Message frame (relay-protocol.md §6). The relay never sees any of this. Only the agent and the client see it.

The plaintext is JSON UTF-8, wrapped in the framing envelope (framing.md) before encryption. No canonical form is required: it is encrypted as a byte blob and re-parsed by the recipient; it is never hashed separately.


1. Common Envelope

Every E2E payload has these base fields, plus kind-specific ones:

{
  "v": 1,
  "kind": "<string>",
  "id": "<uuid-v4>",
  "ts": 1750000000000
}
Field Type Required Meaning
v int yes Payload schema version. 1 here. Different value → receiver discards with log.
kind string yes Discriminant (table §2).
id string (uuid-v4) yes Unique message id. Used for dedup at payload level and for acks.
ts int (unix ms) yes Sender-side creation timestamp. Freshness check (§6).

Common rules:

  • Forward-compat: unknown fields are ignored. An unknown kind is discarded (with log), not a fatal error.
  • Idempotency: the receiver MUST handle every payload idempotently relative to its action identifier (request_id for responses; id for generic dedup).
  • Anti-replay: guaranteed by the nonce counter (crypto.md §6.1); id/ts are additional application-level defences.

2. Kind Catalogue

kind Direction Purpose
inbox_update agent → client Full Inbox snapshot (pending approvals + clarifications + elicitations).
notification agent → client Generic notification (title/body), for informational pushes.
hello client → agent First message after pairing: detailed device_info.
inbox_request client → agent Explicit Inbox snapshot request; agent responds with a targeted inbox_update.
approval_response client → agent Outcome of an approval request.
clarification_response client → agent Answer to a clarification.
elicitation_response client → agent Reply to an MCP elicitation (carries the requested value E2E).
logout client → agent Device removes itself from the namespace.
ack bidirectional Delivery confirmation (optional, for reliability).

3. Agent → Client

3.1 inbox_update — Inbox Snapshot

Full snapshot, not a delta: contains all currently pending items. Idempotent by construction (replaces local state). So a lost push does not cause state loss: the next snapshot realigns.

{
  "v": 1,
  "kind": "inbox_update",
  "id": "0c5b…",
  "ts": 1750000000000,
  "badge": 2,
  "approvals": [
    {
      "request_id": "appr_8f2a…",
      "tool_name": "send_email",
      "agent_label": "Skald",
      "summary": "Send an email to mario@acme.com",
      "detail": "Subject: Q3 Estimate\nBody: …",
      "arguments": { "to": "mario@acme.com", "subject": "Q3 Estimate" },
      "created_at": 1749999990000
    }
  ],
  "clarifications": [
    {
      "request_id": "clar_3b1c…",
      "question": "Proceed with the €240 payment?",
      "context": "Invoice #1234, supplier X",
      "suggested_answers": ["Yes, proceed", "No, cancel"],
      "agent_label": "Skald",
      "created_at": 1749999991000
    }
  ],
  "elicitations": [
    {
      "request_id": "elic_5d7e…",
      "server_name": "ssh",
      "message": "Enter the SSH password for deploy@host",
      "field_name": "password",
      "sensitive": true,
      "is_confirmation": false,
      "created_at": 1749999992000
    }
  ]
}
Field Type Required Notes
badge int yes Total pending item count (= len(approvals)+len(clarifications)+len(elicitations)). Used by the client for badge.
approvals[] array yes May be empty.
approvals[].request_id string yes Action identifier. Stable while the item is pending. Used for response idempotency.
approvals[].tool_name string yes Name of the tool requesting approval (e.g. send_email, execute_cmd).
approvals[].agent_label string yes Human-readable origin label (typically "Skald").
approvals[].summary string yes Short line for notification/card (≤ ~120 chars).
approvals[].detail string no Extended text for the detail screen.
approvals[].arguments object no Raw tool arguments (JSON passed by the LLM). Source of truth for the detail screen: the client shows these so the user knows what they are approving (critical for execute_cmd → show arguments.command). May be absent for tools without arguments. E2E encrypted along with the rest of the payload.
approvals[].created_at int (unix ms) yes When the request was created on the Skald side.
clarifications[] array yes May be empty.
clarifications[].request_id string yes Action identifier.
clarifications[].question string yes Question to display.
clarifications[].context string no Optional context.
clarifications[].suggested_answers array of strings no Pre-defined answers suggested by the LLM. May be empty/absent. The client shows them as quick-tap options; free-form input is always possible too. The choice is sent as clarification_response.answer (§4.3).
clarifications[].agent_label string yes Origin label.
clarifications[].created_at int (unix ms) yes
elicitations[] array yes May be empty. MCP server-initiated input requests (e.g. an SSH/sudo password).
elicitations[].request_id string yes Action identifier. Echoed back in elicitation_response (§4.4).
elicitations[].server_name string yes MCP server that asked for input (e.g. "ssh").
elicitations[].message string yes Prompt to display to the user.
elicitations[].field_name string | null no Key the requested value must be stored under in elicitation_response.content. null for a bare confirmation.
elicitations[].sensitive bool yes When true, the value is a secret: the client SHOULD mask input and MUST NOT cache/persist it.
elicitations[].is_confirmation bool yes When true, this is a yes/no confirmation (no value field); accept/decline suffice and content is omitted.
elicitations[].created_at int (unix ms) yes

Elicitation values never appear here. This snapshot carries only the prompt metadata. The value the user supplies travels only in the client→agent elicitation_response.content (§4.4) and is handed straight to the MCP server; the agent never logs or persists it in clear.

Push privacy. When this snapshot is sent to an offline client, the relay delivers it (encrypted) in the push content-in-push if it fits the APNs/FCM limit. Keep summary/detail short. If it exceeds the limit, the relay sends a wake and the client downloads the snapshot over WS (server.md §5).

3.2 notification — Generic Notification

{ "v":1, "kind":"notification", "id":"…", "ts":, "title":"Skald", "body":"Nightly job completed" }
Field Type Required Notes
title string yes Notification title.
body string yes Notification body.

No response required. Does not affect the badge unless accompanied by an inbox_update.

3.3 ack (optional)

{ "v":1, "kind":"ack", "id":"…", "ts":, "ref_id":"<id of confirmed payload>" }

Confirms that a payload with id == ref_id was received/processed. Optional (store-and-forward

  • idempotent snapshots suffice for v1).

4. Client → Agent

4.1 hello — Post-Pairing Application Handshake

First E2E message the client sends after it is authorised and connected as client. Transfers detailed device_info outside the relay's view.

{
  "v": 1,
  "kind": "hello",
  "id": "…",
  "ts": ,
  "device_info": {
    "platform": "ios",
    "model": "iPhone 16 Pro",
    "os_version": "18.5",
    "app_version": "1.0.0",
    "device_name": "Daniele's iPhone"
  }
}
Field Type Required Notes
device_info.platform string yes "ios" | "android".
device_info.model string no Hardware model.
device_info.os_version string no OS version.
device_info.app_version string no App version.
device_info.device_name string no Human-readable name for the agent's device list UI.

The agent persists this data and shows it in the device list.

4.2 approval_response — Approval Outcome

{
  "v": 1,
  "kind": "approval_response",
  "id": "…",
  "ts": ,
  "request_id": "appr_8f2a…",
  "decision": "approved",
  "reason": null,
  "bypass_secs": 900
}
Field Type Required Notes
request_id string yes MUST match an approvals[].request_id received.
decision enum string yes Only "approved" | "rejected". Other values → agent discards.
reason string | null no Reason (typically for rejected).
bypass_secs int no With decision="approved" only. Approve and register a bypass for similar tools: 900 = 15 minutes, 0 = for the entire session. Absent = single approval (current behaviour). The scope (tool category / MCP server / all) is auto-detected by the agent: the client only sends the seconds.

Agent behaviour (see ../plugins/mobile-connector.md):

  1. Resolves the request via Skald's Inbox/ApprovalManager (resolve(request_id, decision, reason)). If decision="approved" and bypass_secs is present, uses approve_with_bypass instead of simple approve (registers the session bypass with auto-detected scope).
  2. Idempotency: if request_id is already resolved (or no longer pending), the operation is a no-op (log and ignore). Neutralises replays and double deliveries.
  3. Sends a new inbox_update (the snapshot will no longer contain that item) to realign clients.

4.3 clarification_response — Clarification Answer

{
  "v": 1, "kind": "clarification_response", "id": "…", "ts": ,
  "request_id": "clar_3b1c…",
  "answer": "Yes, proceed."
}
Field Type Required Notes
request_id string yes MUST match a clarifications[].request_id.
answer string yes Free-form answer text.

Same request_id idempotency as §4.2.

4.4 elicitation_response — MCP Elicitation Reply

Reply to an elicitations[] entry (§3.1): an MCP server asked for an input the LLM must not see (e.g. an SSH/sudo password). The requested value travels only in this payload's content, sealed E2E — the relay never sees it and the agent hands it straight to the MCP server without logging or persisting it.

{
  "v": 1, "kind": "elicitation_response", "id": "…", "ts": ,
  "request_id": "elic_5d7e…",
  "action": "accept",
  "content": { "password": "hunter2" }
}
Field Type Required Notes
request_id string yes MUST match an elicitations[].request_id.
action enum string yes Only "accept" | "decline" | "cancel". Other values → agent discards. decline rejects the prompt; cancel aborts the whole request.
content object | null conditional Present only with action="accept" for a value prompt: a single key equal to the elicitation's field_name, whose value is the user's input (possibly a secret). Absent/null for decline/cancel and for confirmations (is_confirmation=true). A non-object content is dropped.

Agent behaviour:

  1. Resolves the request via Skald's Inbox (resolve_elicitation(request_id, action, content)), which forwards the outcome to the ElicitationManager and unblocks the waiting MCP call.
  2. Idempotency: a request_id already resolved (or no longer pending) is a no-op.
  3. Sends a new inbox_update to realign clients.

Secret hygiene. content may carry a secret. It is never written to logs, the DB, or any trace on the agent side; it lives only long enough to satisfy the MCP elicitation/create call.

4.5 logout — Device Self-Removal

{ "v":1, "kind":"logout", "id":"…", "ts": }

The agent, on receipt:

  1. removes client_ed25519_pub from the local authorised list;
  2. sends an updated Authorize (without that client) to the relay → the relay closes the device's WS, purges its queue, and forgets its device_token (relay-protocol.md §5);
  3. forgets the client's keys/counters.

Revocation can also be initiated by the agent (lost/stolen device): the user removes it via the Skald UI and the agent sends Authorize without that device. logout E2E is only the "device-initiated" case.

4.6 inbox_request — Explicit Inbox Snapshot Request

The client sends this payload to ask the agent for the current Inbox state. MUST be sent after AuthOk on every WS (re)connection (including app open from a push), because the agent does not receive a reconnect signal from the relay: without inbox_request the client's Inbox would stay empty until a new bus event triggers a broadcast.

{ "v":1, "kind":"inbox_request", "id":"…", "ts": }

No specific fields beyond the common envelope (§1).

Agent behaviour:

  1. Builds the current Inbox snapshot (list_pending()).
  2. Sends an inbox_update (§3.1) targeted to the requester only (not a broadcast): the message is sealed with the requesting client's aes_key, leaving other devices unaffected.
  3. Idempotent and side-effect-free on the Inbox: safe to send on every connection. If there are no pending items, the snapshot has badge:0 and empty arrays.

This follows the targeted request → targeted response pattern. The payload travels on the live channel (Message.live=true, relay-protocol.md §6.4): a stale Inbox snapshot is useless, so route-or-fail is correct — if the agent is offline, the client learns immediately via PeerOffline.

4.7 ack (optional)

Same as §3.3, opposite direction.


5. Inbox State Machine (client side)

            inbox_update (snapshot)
   ┌──────────────────────────────────────┐
   ▼                                        │
[ local list ] ──user approves/rejects──▶ send approval_response
   ▲                                        │  (optimistic: remove card)
   │                                        ▼
   └──────────── next inbox_update ◀─── agent resolves and re-snapshots
  • The client updates the UI optimistically (removes the card on response send), but the source of truth is the next inbox_update. If the response is lost, the item reappears on the next snapshot.
  • Local badge = badge of the last snapshot, minus items already responded to locally (reconciled on next snapshot).

6. Freshness & Validation (receiver side)

For every decrypted E2E payload, the receiver MUST:

  1. verify the nonce counter (> last_seen, crypto.md §6.1) → otherwise discard;
  2. verify v == 1 → otherwise discard with log;
  3. (SHOULD) discard if |now - ts| > 7 days (aligned with the queue TTL): extra defence against very late replays;
  4. validate required fields and types; a malformed payload is discarded without crash;
  5. apply the action idempotently by request_id (responses) or id (generic dedup).

7. Complete Round-Trip Examples

Approval (foreground):

agent  → inbox_update { approvals:[{request_id:"appr_1", tool_name:"send_email", …}], badge:1 }
client → approval_response { request_id:"appr_1", decision:"approved" }
agent  → inbox_update { approvals:[], badge:0 }     // realign

Clarification (background, via content-in-push):

agent  → inbox_update { clarifications:[{request_id:"clar_9", question:"Proceed?"}], badge:1 }
         (relay: client offline → push with encrypted blob)
client → (NSE decrypts, shows notification) → user opens app → clarification_response { request_id:"clar_9", answer:"Yes" }
agent  → inbox_update { clarifications:[], badge:0 }

MCP elicitation (SSH password, secret E2E):

         (MCP `ssh` server calls elicitation/create → agent blocks the tool call)
agent  → inbox_update { elicitations:[{request_id:"elic_5", server_name:"ssh", field_name:"password", sensitive:true}], badge:1 }
client → (masked input) → elicitation_response { request_id:"elic_5", action:"accept", content:{ "password":"hunter2" } }
agent  → (hands content to the MCP server, unblocks the call; value never logged) → inbox_update { elicitations:[], badge:0 }

App opened from notification (reconnect):

client → (connects as role:"client", auth_ok)
client → inbox_request { }                  // live channel (Message.live=true)
agent  → inbox_update { approvals:[…], clarifications:[…], badge:N }   // targeted to requester only