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