Building

Live connections

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

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;     // 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"; request: { method?: "GET" | "POST" | "PUT"; url: string; headers?; body?; timeoutMs? } };

// Every link also takes:
{
  key?: string;                  // name it if you have several
  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 (not on poll links)
  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
}

Poll links wake you with every response; requests are at least 2 s apart and time out at 120 s.

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).

What you return — ConnectionStepResult

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

Field Does
state your new state (≤ 64 KB). Omit to keep the old one. Not encrypted — never store a secret in it.
send frames to write: { link?, text } or { link?, base64 } — ≤ 20 per link per step, all-or-nothing
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
wakeAfterMs call timer after this long with no inbound frame (periodic logic); null clears it
next for a poll link: the next request
messages chat messages (the ChannelInboundMessage shape) — for a channel-owned connection
events { id, text }[], ≤ 20 per step — for an install-owned connection; stored once per id
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 you send per link per step 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
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