Building

Live connections

Socket-only platforms without running a server — AgentParley holds the connection and wakes your plugin only when something matters.

View as Markdown

Some platforms only deliver messages over a connection the receiver keeps open for days: Discord's Gateway, IRC, Matrix sync, Slack Socket Mode, a Home Assistant event stream. A plugin runs for seconds at a time and can't hold a socket. So AgentParley holds it for you.

You describe how to connect and what to do with what arrives. A dedicated service opens the socket, keeps it alive, sends your heartbeats, answers keep-alives, drops the noise you told it to drop, reconnects with backoff, survives AgentParley's own deploys, and wakes your plugin only for frames you care about.

The official Discord plugin is built on this — see the walkthrough.

The handlers

globalThis.connection = {
  open:     (request) => ConnectionOpenResult,     // required
  receive:  (request) => ConnectionStepResult,     // required
  timer?:   (request) => ConnectionStepResult,     // only if you use wakeAfterMs
};

Every call gets your stored state and returns what to do next. Nothing else survives between calls.

open — connect (or reconnect)

request: {
  owner: string;                                   // "channel" or "install" — see "Two kinds of owner"
  reason: "start" | "reconnect" | "handoff";       // handoff = the platform moved you (deploy, rebalance)
  state: unknown | null;                           // your stored state; null on the very first open
  lastClose: { code: number | null; reason: string; by: "remote" | "plugin" | "platform"; link: string | null } | null;
  lastSequence: unknown | null;                    // the last sequence number seen (see `sequence`)
}
returns: ConnectionStepResult & {
  links: LinkSpec[];              // at most 5 sockets
  resumable?: boolean;            // your next open may resume the vendor session after a platform move
  connectsPerMinute?: number;     // 1–600; may only LOWER the platform's connect rate
}

Return { links: [], fail: "…" } to stop for good (for example, a rejected token) — nothing reconnects until the user acts.

type LinkSpec =
  | { transport: "websocket"; url: string; headers?: Record<string, string>; protocols?: string[] }
  | { transport: "tcp"; host: string; port: number; tls: boolean }         // line-based; never put CR/LF inside a frame
  | { transport: "sse"; url: string; headers?: Record<string, string> }
  | { transport: "poll"; everyMs: number; request: HttpRequest };    // everyMs: 30 000 – 86 400 000

type HttpRequest = {
  method?: "GET" | "POST" | "PUT"; url: string; headers?: Record<string, string>; body?: string; timeoutMs?: number;
  oauth?: string;     // a manifest `oauth` key: the service adds `Authorization: Bearer <fresh token>`; you never see it
  account?: string;   // the connection id from host.oauth.accounts(), when more than one is connected
};

// Every link also takes:
{
  key?: string;                  // name it if you have several; unnamed links are "link0", "link1", … in order
  idleTimeoutMs?: number;        // reconnect if nothing arrives for this long
  maxDetachedSeconds?: number;   // 30–300 (default 300): keep the socket while cut off from AgentParley
  filters?: FrameRule[];         // keep/drop rules, applied before any wake (on a poll link: to 2xx replies)
  autoReply?: { textPrefix: string; replacePrefixWith: string }[];   // e.g. IRC PING → PONG, costs no wake
  sequence?: { path: string };   // JSON path of the vendor's sequence number, tracked on every frame
}

The service repeats request every everyMs (the first poll comes after a random delay of up to everyMs) and does not wake you for a poll that finds nothing. A poll costs one HTTPS request from the service and nothing else — no call to AgentParley, no wake, no allowance. Requests time out at 120 s (set a lower timeoutMs if you like), redirects are not followed, a reply over 2 MB counts as a network failure, and the request host must be on your manifest.egress list.

You are woken (with an InboundFrame of kind response) for:

  • a 2xx reply that your filters keep (rules match the first 256 KB of the body; a reply matching no rule is kept), and
  • any 3xx or 4xx except 401, 408 and 429.

After a wake the link pauses until receive returns; polling then resumes on its schedule. Return next to change what it asks for (a new cursor, a new pageToken).

You are not woken for failures the service retries for you: 408, 429 and 5xx replies, and network errors. Retries back off from everyMs upward (doubling, at most 30 minutes between tries) and honour Retry-After (up to one hour). After 5 quiet failures in a row (408/429/5xx) the service posts one note to the connection card so the user can see the outage; the next good reply clears the streak. Five network failures in a row (no HTTP reply at all) close the connection and it reconnects through the normal back-off; any HTTP reply, even a 5xx, resets that counter.

With oauth on the request, a 401 is not delivered to you: the service gets a fresh token and sends the request once more. A second 401 stops the connection with "sign-in required" until the user reconnects the sign-in. A 401 on a request without oauth is an ordinary 4xx and wakes you.

Example: a Gmail watcher that wakes only when the mailbox history moves.

{
  transport: "poll",
  everyMs: 120_000,
  request: { url: "https://gmail.googleapis.com/gmail/v1/users/me/history?startHistoryId=" + cursor
                  + "&historyTypes=messageAdded", oauth: "google", account },
  filters: [
    { when: [{ path: "history", exists: true }], action: "keep" },   // something changed
    { when: [{ textPrefix: "" }], action: "drop" },                   // nothing new: stay asleep
  ],
}

receive — frames arrived

request: {
  state: unknown | null;
  link: string;              // which link
  frames: InboundFrame[];    // up to 50 per call
  lastSequence: unknown | null;
}

type InboundFrame =
  | { id: string; kind: "text"; text: string }
  | { id: string; kind: "binary"; base64: string }
  | { id: string; kind: "sse"; event: string; data: string; lastEventId: string | null }
  | { id: string; kind: "response"; status: number; headers: Record<string, string>; body: string };

A frame's id is the platform's de-duplication key across redelivery (not the vendor's id).

timer — nothing arrived for wakeAfterMs

request: { state: unknown | null; lastSequence: unknown | null }

What you return — ConnectionStepResult

Every field is optional; omit what you don't change.

Field Does
state your new state (≤ 64 KB; a larger one is ignored and the old state kept). Omit to keep the old one. Not encrypted — never store a secret in it.
send frames to write: { link?, text } or { link?, base64 } — ≤ 50 per step; frames beyond that are dropped. Leave out link for the link this step answers; in open or timer, it means the connection's only link, and with several links the frame is dropped
heartbeat { link?, everyMs, jitterFirst?, frame } — the service sends frame every everyMs, no wake. null clears it. A JSON frame may set sequenceAt to have the latest sequence written into it.
filters { link?, rules } — replace a link's keep/drop rules. link defaults as for send, and so does heartbeat's
wakeAfterMs call timer after this long with no inbound frame (periodic logic); null clears it
next { link, request }[] — replace a poll link's repeating request (request may carry oauth)
messages chat messages (the ChannelInboundMessage shape) — for a channel-owned connection
events { id, text }[], ≤ 50 per step — for an install-owned connection; stored once per id. id is up to 100 characters (an event without one is dropped); text is cut at 4,000 characters
reconnect { reason, resetState? } — ask for a reconnect (the platform decides when)
fail a fatal error: stop until the user acts

Filters

The service applies your rules to every frame before deciding whether to wake you. A frame you drop costs nothing.

type FrameRule = { when: FrameCondition[]; action: "keep" | "drop" };

type FrameCondition =
  | { path: string; equals: string | number | boolean | null }   // dot path into JSON; "a[].b" = any element
  | { path: string; exists: boolean }
  | { textPrefix: string }
  | { binaryPrefixBase64: string };

The first rule whose conditions all match decides; a frame matching no rule is kept. Discord's rules keep direct messages, @mentions and replies to the bot, and drop everything else in a server:

// plugins/discord/index.ts
function baseFilters(botId: string | undefined, respondToBots: boolean): FrameRule[] {
  const rules: FrameRule[] = [
    { when: [{ path: "op", equals: 11 }], action: "drop" },            // heartbeat ACKs
    { when: [{ path: "t", equals: "RESUMED" }], action: "drop" },
  ];
  if (!botId) {
    return rules;
  }
  if (!respondToBots) {
    rules.push({ when: [{ path: "d.author.bot", equals: true }], action: "drop" });
  }
  rules.push(
    { when: [{ path: "t", equals: "MESSAGE_CREATE" }, { path: "d.guild_id", exists: false }], action: "keep" },
    { when: [{ path: "t", equals: "MESSAGE_CREATE" }, { path: "d.mentions[].id", equals: botId }], action: "keep" },
    { when: [{ path: "t", equals: "MESSAGE_CREATE" }, { path: "d.referenced_message.author.id", equals: botId }], action: "keep" },
    { when: [{ path: "t", equals: "READY" }], action: "keep" },
    { when: [{ path: "op", equals: 0 }], action: "drop" },
  );
  return rules;
}

Heartbeats, sequences and resume

A typical gateway: on HELLO, identify (or resume) and start a heartbeat; track the sequence number; on READY, store the session id so the next open can resume.

// plugins/discord/index.ts (abridged)
if (payload.op === 10) {                                  // HELLO
  const identify = canResume
    ? { op: 6, d: { token, session_id: state.sessionId, seq: sequence } }
    : { op: 2, d: { token, intents: /* … */, properties: { os: "linux", browser: "agentparley", device: "agentparley" } } };
  send.push({ link: request.link, text: JSON.stringify(identify) });
  heartbeat = {
    link: request.link,
    everyMs: payload.d.heartbeat_interval,
    jitterFirst: true,
    frame: { json: { op: 1, d: null }, sequenceAt: "d" },  // the service writes the latest sequence into d
  };
}

With sequence: { path: "s" } on the link, the service tracks s on every frame — even dropped ones — and hands it back as lastSequence. Declare resumable: true and, after a deploy moves your socket (reason: "handoff"), you resume the vendor session instead of logging in again.

Two kinds of owner

Owner Created by Your receive returns Used for
"channel" the user connecting a channel (Channels → Connect a channel) messages → chats with the agent chat platforms: Discord, IRC
"install" the user pressing Connect under Live connection on the installed plugin's page events → fires On event schedules event streams: "when the front door opens, tell my House agent"

An install-owned connection requires manifest.installOwnedConnection: true. The same code can serve both — hello-live branches on request.owner:

// infrastructure/plugins/hello-live/index.ts
if (state.owner === "install") {
  events.push({ id: dispatch.id, text: dispatch.text });
} else {
  messages.push({
    conversationId: dispatch.conversationId,
    messageId: dispatch.id,
    sender: { id: dispatch.senderId ?? "tester", displayName: dispatch.senderName ?? "Tester" },
    text: dispatch.text,
    isGroup: !!dispatch.guildId,
    mentionsBot: !!dispatch.mention,
  });
}

Each event you emit fires the account's schedules of kind On event that listen on that connection; the schedule's prompt runs on its agent with your event text.

Sending outside a step — host.connection.send

From a channel's send handler (a reply the agent wrote), write straight onto the open socket:

// infrastructure/plugins/hello-live/index.ts
globalThis.channel = {
  send: async (request) => {
    const frame = { op: 99, d: { conversationId: request.conversationId, text: request.text } };
    await host.connection!.send([{ text: JSON.stringify(frame) }], { idempotencyKey: request.deliveryId ?? undefined });
  },
};

At most 20 frames per call, accepted or refused whole; pass the same idempotencyKey on a retry so nothing is written twice. Permission host:connection:send is derived from the call. (Discord sends replies over REST instead, which is why it doesn't need this.)

Where it may connect

Links may only reach hosts in your manifest.egress or a hosts setting the user filled in. The service also refuses private, loopback, link-local and cloud-metadata addresses, checked on the address it actually dials.

Limits

Links per connection 5
Inbound frames per receive call 50
Frames in send per step 50
Frames per host.connection.send call 20
state 64 KB, not encrypted
Inbound frame / TCP line 1 MB / 64 KB
Outbound frame 64 KB
Outbound frames 120 per minute (heartbeats and auto-replies don't count)
Heartbeat interval / idle timeout at least 5 s / at least 10 s
wakeAfterMs 1 s – 24 h
Poll everyMs 30 s – 24 h
Filters 30 rules per link, 5 conditions per rule, 8 path segments; 5 auto-reply rules
Opens 300 per day
open / receive / timer wall clock 15 s / 10 s / 10 s
TCP ports 25, 465 and 587 (mail) are refused
Detached budget 30–300 s
Live connections per account Free 1, Solo 5, Entrepreneur 20
Wakes (open / receive / timer) count against the hourly plugin-wake allowance; reconnects the platform causes are free