Skip to content

Latest commit

 

History

History
908 lines (786 loc) · 36.1 KB

File metadata and controls

908 lines (786 loc) · 36.1 KB

Native sync and command API v1

Status: M11 vertical slice frozen; M12A automation and M12B external-content extensions implemented

For setup, interface selection, reference-client commands, and a minimal client example, start with the native gateway practical guide. This document is the exact wire and security contract.

The native gateway is an optional structured adapter inside the IRC daemon. It shares accounts, installations, live channel admission, event history, delivery checkpoints, content epochs, authorization, rate policy, and the serialized SQLite writer with the IRC adapters. It is not a second chat backend.

Traditional IRC and IRC-over-WebSocket remain complete compatibility paths. Native clients may use this API for exact resume and structured extensions; grant-scoped automation and its reference MCP adapter now use the same boundary, and provider-hosted catalogue/capability exchange uses five additional scoped operations without moving package bytes into chat storage.

Endpoints and transport

The existing TLS control listener owns both endpoints:

  • POST /v1/native-sessions authenticates a human with bare account/password HTTP Basic or automation with account/key-id plus its ak1... app key, then returns one short-lived session ticket.
  • GET /v1/sync upgrades to WebSocket only when the client offers the irc.native.v1 subprotocol. Every application message is one UTF-8 JSON object.

Remote plaintext remains impossible on the control listener. Browser requests must pass the existing exact same-origin check. The API sets no cookie, enables no permissive CORS policy, and accepts no bearer secret in a URL.

The session response is:

{
  "api_version": "1",
  "ticket": "nt1.<base64url secret>",
  "expires_in_seconds": 30,
  "device_id": "019f...",
  "installation_token": "dr1.019f....<secret>",
  "access": {
    "operations": [
      "channel.join",
      "sync.resume",
      "history.read",
      "roster.read",
      "delivery.ack",
      "message.send",
      "automation.invoke",
      "automation.commands.set",
      "content.search",
      "content.request",
      "content.package.upsert",
      "content.package.remove",
      "content.transfer"
    ],
    "channels": null
  }
}

A human client's first response includes installation_token; it sends that value later in Telex-Installation. A bound response omits the field while returning the same device_id. The token identifies a delivery cursor only and cannot authenticate. Automated responses use the app-key ID as device_id and never accept an installation header.

A ticket contains 256 random bits, is stored only as a keyed lookup digest in process memory, expires after 30 seconds, and is removed on its first exchange. At most 256 unexpired tickets exist. A process restart invalidates them. The ticket grants no durable authority and never replaces the account password or app key.

Attenuated tickets

An empty request body preserves the unrestricted human-client behavior above. An issuer can instead ask the server to attenuate that one ticket:

{
  "operations": ["channel.join", "sync.resume"],
  "channels": ["#news", "#research"]
}

Operations are an explicit subset of the thirteen v1 command names. channels is either null for every channel the account itself may enter or a list of at most 32 valid IRC channel names; an empty list grants no channel. The server RFC1459-casefolds, deduplicates, and echoes the effective access in both the HTTP response and ready. Every command and live delivery rechecks it, and a denial returns scope_denied without expanding the account's ordinary channel authority.

This is capability attenuation, not a new durable credential system. A trusted launcher can retain the account password or app key and hand a short-lived, one-use, read-only ticket to a less-trusted tool. A process that possesses that authentication secret can simply request another scope and therefore is not confined by this mechanism. Durable bot/agent manifests and channel grants further restrict every automated session and cannot be overridden by a ticket or app key.

Session establishment

The first WebSocket application frame must arrive within five seconds:

{
  "type": "hello",
  "api_version": "1",
  "ticket": "nt1.<base64url secret>",
  "nickname": "Alice"
}

The server consumes the ticket, rechecks that the account is enabled and has the immutable kind captured at authentication, validates and atomically claims the IRC-compatible nickname, registers the account and installation/key as a live client, and derives its username from the canonical account name. Automated accounts additionally require their mandatory manifest and at least one current channel grant. A successful response contains the account's own typed identifiers, the current content epoch, and the active bounds:

{
  "type": "ready",
  "api_version": "1",
  "session_id": "019...",
  "account_id": "019...",
  "device_id": "019...",
  "content_epoch": "019...",
  "access": {
    "operations": ["channel.join", "sync.resume"],
    "channels": ["#news", "#research"]
  },
  "limits": {
    "channels": 32,
    "history_batch": 100,
    "roster_page": 100,
    "incoming_frame_bytes": 16384,
    "outgoing_frame_bytes": 131072,
    "invocation_context_events": 10,
    "catalogue_results": 25,
    "bulk_catalogue_results": 50,
    "content_capability_seconds": 300
  }
}

The session ID is ephemeral presentation metadata. Accounts, devices, and delivery checkpoints remain the only durable session and cursor identities. Closing the socket removes the live participant and emits ordinary PART/QUIT presentation to compatible clients.

Client commands

Every command is a tagged JSON object. Unknown fields, unknown command names, non-canonical identifiers, invalid strings, and frames outside the documented bounds are rejected. request_id and operation_id values are canonical UUIDv7 identifiers.

Join a channel

{
  "type": "channel.join",
  "request_id": "019...",
  "channel": "#general",
  "key": null
}

Joining invokes the same public/invite-only membership, account ban, +k, +l, channel-count, and live policy-revision checks as IRC JOIN. A native subscriber is therefore a visible member, not a hidden history reader. The committed result includes the stable conversation ID, display name, effective retention, and current automation trust snapshot:

{
  "type": "result",
  "api_version": "1",
  "request_id": "019...",
  "operation": "channel.join",
  "status": "joined",
  "result": {
    "kind": "channel",
    "conversation_id": "019...",
    "channel": "#general",
    "retention": {"mode": "finite", "milliseconds": 2592000000},
    "automation": [{
      "account_name": "Helper",
      "kind": "agent",
      "purpose": "Answers explicit channel questions",
      "processing_boundary": "external",
      "processor": "Example Model Service",
      "data_handling": "Granted text is sent to the named service",
      "permissions": ["invocation.receive", "message.reply"],
      "history_window_seconds": null,
      "invocation_context_events": 10,
      "policy_revision": 7
    }],
    "automation_chatter_enabled": false,
    "automation_guardrails": {
      "paused": false,
      "window_seconds": 60,
      "account_message_budget": 60,
      "account_byte_budget": 32768,
      "channel_message_budget": 240,
      "channel_byte_budget": 131072,
      "cooldown_seconds": 30,
      "repeated_content_limit": 3,
      "causal_signature_limit": 2,
      "channel_cooldown": null,
      "account_cooldowns": []
    },
    "automation_policy_revision": 7
  }
}

The automation list is the durable grant roster, not merely current presence; an offline history reader remains disclosed. Automated accounts can enter only an existing explicitly granted channel, cannot create a public room by naming it, and do not use a human membership role or shared +k secret to bypass that grant. Bans still win. The ticket must include channel.join and permit the canonical channel name.

The guardrail object is also a complete replacement snapshot. Cooldowns name an account or channel scope, a bounded reason, and an absolute Unix millisecond expiry; account cooldowns additionally name the disclosed account. The durable policy revision may remain unchanged when only this short-lived operational state changes.

Resume delivery

{
  "type": "sync.resume",
  "request_id": "019...",
  "conversation_id": "019...",
  "limit": 100
}

The conversation must currently be joined by this session. The server reads after the existing (account, device, conversation) delivery checkpoint and returns an ascending event_batch. At most 100 retained events appear. If the batch is not exhausted, the client durably processes and acknowledges it before requesting the next page. Once exhausted, the subscription becomes live. Resume and live delivery require sync.resume in the ticket.

Each retained event contains its event and conversation IDs, offset, receive time, message kind, retained text, author presentation, and optional native provenance. Offsets may have gaps because attribution-free retention tombstones remain valid cursor anchors but are not released as history. Events do not expose another participant's device, address, local session, or internal account ID.

The subscription becomes live only after an exhausted page. Commit-ordered notifications are filtered against current live membership again immediately before delivery. Events committed while a joined session has not yet completed resume use a bounded in-memory handoff queue and are deduplicated by the durable conversation offset. Queue or broadcast lag closes the stream with stream_lagged; reconnect and checkpoint resume are the recovery path.

For a human account, resume retains the ordinary device semantics above. For a bot or agent, one serialized store operation rechecks the current channel grant and applies its history.read window before releasing retained events. Without history authority, resume establishes only a live barrier when the grant has a live or invocation input class; a publish-only account cannot use resume. Future human events require live.human.read. Future bot/agent events require live.automation.read plus the channel's default-off chatter switch. Retained bot/agent events likewise require the chatter switch, and self-authored events are never returned as automation input. An active moderator pause or applicable cooldown rejects resume and suspends an existing automated live subscription. The adapter explicitly resumes after the reported state clears. Its durable checkpoint makes retained events recoverable under the then-current grant; live_only content is not buffered by the server.

Read retained automation history

{
  "type": "history.read",
  "request_id": "019...",
  "conversation_id": "019...",
  "before_event_id": null,
  "limit": 100
}

This is a separate, read-only retained-history operation for an automated account. The session must be joined, its ticket must allow history.read, and the current channel grant must contain the permission with a finite server-side window. A human native client uses the ordinary history and delivery APIs instead.

The server returns the newest available page in ascending channel order:

{
  "type": "history_page",
  "api_version": "1",
  "request_id": "019...",
  "conversation_id": "019...",
  "events": [],
  "next_before_event_id": null,
  "exhausted": true
}

When more history exists, next_before_event_id is the oldest event in the page. Supply it as the next request's exclusive before_event_id. The same serialized read rechecks the current grant, history window, pause/cooldown, automation-chatter switch, author kind, and self-event exclusion. Returned events are ordinary authorized history without invocation context.

Unlike sync.resume, this operation neither reads nor advances the deployment delivery checkpoint and never establishes a live subscription. Events in a successful page do enter the session's bounded delivered-cause set, so an account with message.reply can causally reply to one without first moving its durable delivery cursor. This separation lets an MCP host page context on demand without redefining “processed delivery.”

Read the current channel roster

{
  "type": "roster.read",
  "request_id": "019...",
  "conversation_id": "019...",
  "after_nickname": null,
  "limit": 100
}

This is an on-demand presence snapshot for an automated account. The session must be joined, its ticket and channel allowlist must permit roster.read, and the current channel grant must still contain that permission. The server also rechecks moderator pause and applicable account/channel cooldown state.

{
  "type": "roster_page",
  "api_version": "1",
  "request_id": "019...",
  "conversation_id": "019...",
  "entries": [
    {"nickname": "Alice", "prefix": "@", "bot": false},
    {"nickname": "Helper", "prefix": "", "bot": true}
  ],
  "next_after_nickname": null,
  "exhausted": true
}

Each active connection contributes one entry, including the requester. Two devices using different nicknames therefore remain two entries even when they belong to one account. prefix is the current NAMES role projection (@, +, or empty), and bot is the server-asserted +B state for either a bot or agent account. Entries deliberately omit account names and IDs, device/session IDs, username/host/address/TLS data, offline membership, last-seen state, and history.

Pages are strictly RFC1459-casefolded nickname order. When more entries exist, next_after_nickname is the last nickname returned; send it as the next exclusive after_nickname. Membership can change between calls, so a multi-page traversal is weakly consistent. The operation stores nothing, advances no cursor, and does not subscribe the automation to JOIN/PART changes.

Acknowledge durable delivery

{
  "type": "delivery.ack",
  "request_id": "019...",
  "conversation_id": "019...",
  "event_id": "019..."
}

The event must belong to the joined conversation. The existing monotonic delivery checkpoint advances transactionally or returns its unchanged current position. Acknowledgement means that this device claims durable processing; it does not change the account-wide human read marker. It separately requires delivery.ack in the ticket. An automated session may acknowledge only an event the server actually released in that session; knowing or guessing another event ID is insufficient.

Publish a message

{
  "type": "message.send",
  "operation_id": "019...",
  "conversation_id": "019...",
  "text": "hello",
  "cause_event_id": null
}

The sender must currently be joined, have message.send in its ticket, and fit the same IRC-visible line bound. An automated sender additionally needs the current channel's message.publish permission when no cause is supplied, or message.reply when a cause is supplied. That permission check and the append share one SQLite transaction. The optional cause must name a retained earlier event in the same conversation. The operation ID, a SHA-256 digest of the canonical (conversation, text, cause) tuple, and the cause are committed atomically with the ordinary event. Retrying an identical retained operation returns the original event without another IRC or native broadcast; reusing the ID with different content is rejected. The request digest and originating account stay private store metadata. Authorized event recipients see only the operation ID and optional cause.

For a new automated operation, the current account/channel message and byte budgets, repetition signature, causal-loop bound, pause, and cooldown are checked and charged atomically with append. Exact retries are resolved first and consume no second allowance. automation_paused is non-retryable until a policy replacement changes state; automation_cooldown is retryable after the expiry carried by the subsequent guardrail snapshot.

For automation, the cause must also be among the bounded set of events actually released in the current session. This prevents a guessed event ID or broader database visibility from manufacturing a reply edge. Exact retained-operation retries are resolved before that ephemeral proof check so a reconnect can learn the result of its own already committed write without committing it again.

Idempotency metadata expires when retention prunes its event and disappears on content reset. A causal edge is also cleared as soon as its cause is pruned. For a live_only channel this privacy rule makes the idempotency record expire at the initial commit, so a later retry has no durable deduplication window.

The resulting event is ordinary channel history and is projected to IRC and native recipients from the same commit. The gateway does not add native-only messages.

Invoke an automated participant

{
  "type": "automation.invoke",
  "operation_id": "019...",
  "conversation_id": "019...",
  "account_name": "Helper",
  "text": "what did we decide?"
}

Only a joined human native account may invoke an automated participant, and the ticket must independently allow automation.invoke for that channel. The target name is resolved without disclosing whether an account, manifest, or grant is missing. The target must be an active bot or agent with the current channel's invocation.receive permission. Automated callers use the ordinary live/chatter and causal-reply paths instead; they cannot recursively invoke through this operation.

Pause and cooldown are folded into the same opaque target decision. A human therefore receives invocation_unavailable, not evidence that a named account exists or which guardrail currently applies to it.

The committed event is an ordinary visible channel message rendered as @Helper: what did we decide?. IRC clients, native humans, and other readers with ordinary channel authority see that text, but not the private target relation. Only the addressed account receives an additional object:

{
  "invocation": {
    "context": [
      {"event_id": "019...", "offset": 41, "kind": "message", "text": "..."}
    ]
  }
}

The server selects the zero-to-ten retained events allowed by the target's current channel grant immediately preceding the invocation, in ascending order. The ready-frame limit of ten is a protocol maximum, while each automation roster entry reports its effective invocation_context_events. The server excludes the target's own messages. Bot/agent-authored context is included only when the channel's automation-chatter switch is currently enabled. Context events cannot contain nested invocation objects. This bounded release is authority supplied by invocation.receive; it does not silently grant history.read or a passive live feed.

Retained invocation targeting and native provenance are committed atomically. An invocation-only agent can reconnect and resume missed addressed events from its app-key checkpoint while unrelated history remains absent. Delivery rechecks the current grant before selecting context. Retention pruning deletes the private target relation and provenance before leaving the ordinary attribution-free cursor tombstone; live_only rooms persist neither relation. The operation ID hashes the canonical (conversation, target, text) tuple, so an exact retained retry returns the original event and a conflicting reuse is rejected without another broadcast.

Publish automation command discovery

{
  "type": "automation.commands.set",
  "request_id": "019...",
  "conversation_id": "019...",
  "expected_revision": 0,
  "commands": [
    {
      "name": "latest",
      "usage": "<topic>",
      "description": "Find the latest item for a topic"
    }
  ]
}

This operation atomically replaces one automated account's optional command catalogue in one joined channel. An empty commands array clears it. Command names omit the leading !, are ASCII lowercase after normalization, contain only letters, digits, underscores, or hyphens, and occupy at most 32 bytes. usage is optional and occupies at most 80 UTF-8 bytes; the required description occupies at most 160. Neither text field accepts control or bidirectional-formatting characters.

Publication requires an automated-account app key, an attenuated ticket that allows automation.commands.set, current membership in that channel, and a channel grant containing live.human.read. The catalogue is discovery metadata only: it grants no authority, routes no input, and does not execute a command. A leading !latest topic remains an ordinary PRIVMSG visible to all clients and reaches the automation only through its independently granted live event stream.

expected_revision provides compare-and-set replacement. The response status is applied, unchanged, or stale; exact replacement retries are unchanged, while a stale response returns the current catalogue and revision so the publisher can decide whether to retry:

{
  "type": "result",
  "api_version": "1",
  "request_id": "019...",
  "operation": "automation.commands.set",
  "status": "applied",
  "result": {
    "kind": "automation_commands",
    "conversation_id": "019...",
    "revision": 1,
    "snapshot_revision": 4,
    "commands": [
      {
        "name": "latest",
        "usage": "<topic>",
        "description": "Find the latest item for a topic"
      }
    ],
    "changed": true
  }
}

A bot may declare at most 32 commands, and one channel may contain at most 128 commands across admitted automations. Invalid declarations return invalid_command_catalogue; aggregate exhaustion returns catalogue_full. Missing publication authority returns scope_denied, and a session that has not joined the target returns not_joined. Removing the account's channel grant, or narrowing it to remove live.human.read, removes its catalogue.

Human IRC clients may negotiate the custom telex/bot-commands capability, which depends on batch and message-tags. The server sends the complete channel snapshot on JOIN and after every applied replacement as one BATCH telex/bot-commands; each TELEXCOMMAND entry carries the account, command name, and base64url-encoded usage and description. A receiver replaces its prior snapshot only after the closing BATCH. Clients that do not negotiate the custom capability receive no catalogue traffic.

Search external content

{
  "type": "content.search",
  "request_id": "019...",
  "conversation_id": "019...",
  "query": "event plan",
  "cursor": null,
  "provider_id": null,
  "limit": 20,
  "bulk": false
}

The session must be joined and the ticket must allow content.search for that channel. Humans use ordinary current channel admission. Automated callers also need content.consume; bulk search additionally requires content.bulk. Search, query rate, package visibility, active provider state, and optional provider filtering are evaluated by the serialized store operation. A result has kind: "catalogue", bounded structured descriptors, next_cursor, and the echoed bulk flag. Named automation lists and provider request-base URLs are never projected.

Individual pages permit at most 25 results and bulk pages at most 50. A page is shortened with a valid continuation cursor when maximal descriptor escaping would otherwise exceed the 128 KiB server-frame ceiling.

Request external content

{
  "type": "content.request",
  "operation_id": "019...",
  "conversation_id": "019...",
  "provider_id": "019...",
  "package_id": "event-plan-2026",
  "bulk": false
}

content.request requires the same joined/ticket scope and independently checks visibility, consumption, requester kind, current automation grants, provider/package bulk opt-in, per-hour request and claimed-byte quotas, and outstanding capability count. It returns kind: "content_capability" with the same descriptor, a five-minute provider URL, expiry, and the explicit network-address/provider warning.

The URL is requester-private, unretained, and never broadcast as an event. Its ct1 token has 256 random bits; only a SHA-256 digest is stored. Exact operation retries return duplicate and a null URL without charging or minting again. The provider consumes the token through the authenticated control redemption endpoint. See external content references for the assertion, privacy boundary, legacy XDCC presentation, and fixed budgets.

Publish a provider package

An automated provider uses content.package.upsert after joining its registered local channel:

{
  "type": "content.package.upsert",
  "request_id": "019...",
  "conversation_id": "019...",
  "package_id": "event-plan-2026",
  "display_name": "Annual event plan",
  "description": "Planning notes and print assets",
  "filename": "event-plan-2026.tar.zst",
  "byte_size": 7340032,
  "media_type": "application/zstd",
  "sha256": null,
  "available_until_ms": null,
  "visibility": "human_members",
  "consumption": "human_members",
  "bulk_enabled": false,
  "named_automation": []
}

The ticket must allow content.package.upsert; the store rechecks that this automated account owns an unpaused provider registration and still has message.publish. The result returns the normalized descriptor. A package using human_members_and_named_automation in either policy must supply one to 32 unique active bot/agent names that currently have content.consume; the list is policy input and never catalogue output.

content.package.remove takes request_id, conversation_id, and package_id, and returns removed or unchanged. Removing a package also removes its unexpired capabilities by channel-owned cascade.

Serve a traditional DCC transfer

An automated provider with a registered DCC endpoint may request content.transfer. After it joins the registered channel, an authorized legacy XDCC SEND produces this private server push to one eligible provider session:

{
  "type": "content_transfer_request",
  "api_version": "1",
  "transfer_id": "019...",
  "conversation_id": "019...",
  "provider_id": "019...",
  "package_id": "event-plan-2026",
  "filename": "event-plan-2026.tar.zst",
  "byte_size": "7340032",
  "sha256": null,
  "dcc": {
    "advertised_ipv4": "203.0.113.20",
    "first_port": 45000,
    "last_port": 45015
  },
  "expires_at_ms": 1753000030000
}

The session either opens one listener and offers an approved port:

{"type":"content.transfer.offer","transfer_id":"019...","port":45000}

or declines it:

{"type":"content.transfer.decline","transfer_id":"019..."}

An accepted offer returns a correlated result, atomically consumes the capability, and causes Telex to emit conventional CTCP DCC SEND to the exact requesting IRC connection. The provider and IRC client then exchange bytes directly; no file data crosses this WebSocket or the IRC daemon.

If that client sends conventional DCC RESUME, the assigned provider session receives:

{
  "type": "content_transfer_resume",
  "api_version": "1",
  "transfer_id": "019...",
  "position": "1048576"
}

After preparing the file stream at exactly that offset, it replies:

{
  "type": "content.transfer.resume.accept",
  "transfer_id": "019...",
  "position": "1048576"
}

Telex then sends the matching CTCP DCC ACCEPT. Byte counts and resume positions are decimal strings where projected by the server so JavaScript clients retain full unsigned 64-bit precision. After the transfer ends, the provider should release the ephemeral mapping:

{"type":"content.transfer.finish","transfer_id":"019..."}

The pending offer lasts 30 seconds and an accepted resume mapping lasts at most one hour. The exact provider or requester connection closing removes it sooner. An endpoint address and at most 256 inclusive ports are operator registration policy; the provider can select only one port from that range.

Server messages and private results

The server sends ready, result, event_batch, history_page, roster_page, event, automation_policy, content_transfer_request, content_transfer_resume, and error application messages. Results are requester-private control traffic. They are not retained events, direct-message conversations, or replayable social edges.

An applied grant, chatter, guardrail, or pause mutation sends a full replacement to every joined, in-scope native session. A newly started cooldown sends the same replacement without advancing the durable revision:

{
  "type": "automation_policy",
  "api_version": "1",
  "conversation_id": "019...",
  "policy_revision": 8,
  "automation": [],
  "automation_chatter_enabled": false,
  "automation_guardrails": {
    "paused": true,
    "window_seconds": 60,
    "account_message_budget": 60,
    "account_byte_budget": 32768,
    "channel_message_budget": 240,
    "channel_byte_budget": 131072,
    "cooldown_seconds": 30,
    "repeated_content_limit": 3,
    "causal_signature_limit": 2,
    "channel_cooldown": null,
    "account_cooldowns": []
  }
}

The bounded roster uses the same participant objects as channel.join. A lagged policy receiver closes with policy_refresh_required; continuing with a silently stale processing disclosure is not permitted.

WebSocket ping/pong frames provide transport liveness. A lagged native event receiver is closed with a resumable error instead of silently dropping events; the client reconnects and resumes from its durable checkpoint. Duplicate delivery before acknowledgement is expected and safe.

Content reset remains a stopped-service operation. Every new ready frame therefore reports the post-restart content epoch; a client that retained an old epoch discards its old conversation map before joining and resuming again.

Reference-client adoption

The dependency-free ECMAScript library exposes NativeSession and NativeDurableSync. It bounds and validates ticket/server frames, creates canonical UUIDv7 request and operation IDs, correlates requester-private results, and commits each history page or live event to the existing file or IndexedDB store contract before acknowledging its last event. Storage failure closes the stream without advancing the checkpoint. It also validates and stores the complete automation snapshot, accepts only nondecreasing policy replacements, distinguishes bot and agent event authors, validates bounded non-nested invocation context, and emits an automationpolicy client event. The terminal renders purpose, processing boundary, exact grants, history window, invocation-context allowance, data handling, chatter state, guardrail limits, pause, and cooldowns before restoring cached messages; its /invoke ACCOUNT text command exercises the visible invocation operation. The same library validates catalogue descriptors and capabilities, publishes provider packages, keeps 64-bit package sizes as decimal strings, and never fetches a returned URL. The terminal's /catalog [query] and /get PROVIDER-ID PACKAGE-ID commands show provider disclosure and the address warning before printing the one-use URL.

NativeSession.setAutomationCommands() publishes a revisioned atomic command catalogue for an admitted automated account. The shared IRC session negotiates telex/bot-commands, validates each bounded snapshot batch, and emits one automationcommands event only after the closing batch. The bundled web client uses that event for first-token ! completion without adding catalogue frames to chat history or the server console.

The small native terminal client exercises this vertical slice against the real daemon. The richer terminal and static web clients continue to use IRC/IRC-over-WebSocket for DMs, presence, moderation, and the complete compatibility surface. Each human installation retains its own opaque cursor token; using one token in two adapters means sharing one server checkpoint regardless of their separate local caches. Automated deployments receive the same isolation from distinct app keys.

The reference MCP adapter attenuates its one-use ticket to the six JOIN/resume/history/roster/ack/send operations and its explicit channel list. Its private durable inbox retains the complete validated invocation envelope before acknowledgement, while MCP resources filter cached event classes against the current grant and guardrail state. MCP tools still submit ordinary message.send operations, so the same delivered-cause proof, idempotency, budgets, loop suppression, pause, and cooldown checks apply.

Bounds

  • First application frame: five seconds.
  • Inbound application frame: 16 KiB.
  • Outbound application frame: 128 KiB.
  • Joined channels: 32, shared with the IRC runtime limit.
  • History page: 1 through 100 events.
  • Current roster page: 1 through 100 active nicknames.
  • Pending session tickets: 256 for 30 seconds.
  • Ticket channel allowlist: 32; operation set: thirteen fixed v1 names.
  • Automation roster: 32 accounts per channel; delivered causal-proof window: 1,024 event IDs per native session; invocation context: 0 through 10 retained events per channel/account grant.
  • Automation command catalogue: 32 commands per automated account/channel and 128 across one channel; command name: 32 bytes; usage: 80 UTF-8 bytes; description: 160 UTF-8 bytes.
  • Automation operating window: one minute; account maximum: 600 messages and 512 KiB; channel maximum: 10,000 messages and 8 MiB; cooldown: one second through one hour; repeated content: fourth equal normalized message; causal loop: third equal signature within at most 16 ancestors.
  • External catalogue query: 128 bytes; individual page: 25 descriptors; bulk page: 50 descriptors; capability lifetime: 300 seconds; provider catalogue: 10,000 packages; DCC offer: 30 seconds; active DCC resume mapping: one hour; registered DCC range: at most 256 IPv4 ports. The fixed per-hour requester budgets are documented in external content references.
  • Store and outbound queues: existing bounded daemon queues.
  • Protocol, history, message, and administration commands: the corresponding configured token buckets, with visible typed throttling errors.

Threat model and privacy boundary

  • A ticket theft permits one connection for at most its remaining short lifetime and only its echoed effective access. Tickets are never logged, persisted, placed in URLs, or reusable.
  • TLS protects credentials, tickets, frames, and content in transit to this server. The server and its operator can read retained plaintext; this is not end-to-end encryption.
  • Account disable, human password change, and live channel revocation remove native sessions through the same runtime reconciliation as IRC clients. Automation app-key revocation blocks later ticket issuance.
  • Authorization occurs atomically inside retained-history and publication store operations and again before each live event. No adapter receives a raw database handle.
  • A native session cannot observe a channel merely because it knows a conversation ID. It must pass current admission and remain joined.
  • Automation receives the narrower view selected by its current per-channel grant. Possession of an automation app key or unrestricted ticket cannot override that durable authority.
  • Automation budgets are serialized with event append and shared across every app key for the account and room; a reconnect cannot reset them. Transient loop digests and idle counters age out through ordinary maintenance.
  • Content search/request budgets are likewise serialized across installations. Gated descriptors contain no provider URL; capability tokens are private, digest-only at rest, and redeemable once by the authenticated provider. Direct provider HTTPS or DCC still reveals the downloader's address to that provider. DCC also reveals the provider's registered address and ordinarily does not encrypt the byte stream.
  • The daemon can stop future release after revocation but cannot erase content already retained by a client, model provider, or external tool.

Deliberate v1 omissions

  • Re-expressing every IRC moderation, presence, DM, and discovery command.
  • Hidden passive subscribers or unauthenticated webhooks.
  • Cookies, OAuth providers, raw database queries, or arbitrary event filters.
  • Model hosting, prompt/tool execution, provider byte hosting, or media proxying.