Building
Host API reference
Every member of the host object, its arguments, its result, and when it is present.
host is the only way plugin code reaches anything outside the sandbox. It's a global — never import it, never
alias it (const a = host.agents hides the call from the permission scan).
Presence is the grant. A member you weren't granted, or that makes no sense in the current invocation, is
undefined. Use host.x!.y() when you know it's there, if (host.x) when it may not be.
Ids are strings. Every id (session, agent, todo, message, notify, model, installation…) is a string, because the underlying 64-bit numbers don't fit a JavaScript number. Compare and store them as strings.
Errors. Host calls reject with an Error whose message starts with a code, e.g.
"rate_limited: …", "bad_arguments: …", "agent op agents.update failed: 404".
| Member | Needs | Section |
|---|---|---|
host.log |
— | log |
host.settings |
— | settings |
host.fetch |
manifest.egress |
fetch |
host.crypto |
— | crypto |
host.session |
an agentApi:* permission |
session |
host.agents |
agentApi:agents.* |
agents |
host.models |
agentApi:models.read |
models |
host.workspace |
host:workspace:* + an agent in context |
workspace |
host.media |
host:media:transcode |
media |
host.session.llm, host.llm, host.decide |
context | models from plugin code |
host.ssh |
host:ssh:command, SSH hooks only |
ssh |
host.connection |
host:connection:send, a live connection you own |
connection |
host.log
host.log(level: "debug" | "info" | "warn" | "error", message: string): void
Fire-and-forget. Lines appear in the logs panel of the installed plugin's page for 24 hours.
host.settings
host.settings.get(key: string): string | number | boolean | null
host.settings.getSecret(key: string): Promise<string>
getreturns a non-secret setting's value (itsdefaultuntil the user saves one). Ahostsfield returns a JSON array encoded as a string —JSON.parseit. A key you didn't declare throws.getSecretreturns asecretfield's value. It is fetched only when you call it.- In a model provider handler these read the provider the user added; in a channel
handler,
scope: "channel"fields read that channel's values.
host.fetch
host.fetch(request: {
method?: string; // default "GET"
url: string; // http or https, host must be allowed
headers?: Record<string, string>;
body?: string;
bodyEncoding?: "utf8" | "base64"; // "base64" to send binary
responseEncoding?: "utf8" | "base64"; // "base64" to receive binary (audio, images)
}): Promise<{ status: number; headers: Record<string, string>; body: string }>
- Only hosts in
manifest.egressor ahostssetting (rules). - 60 requests per minute per installation, 10 s timeout, 5 MB response.
- Any HTTP status resolves — check
status. Rejects withFetchTimeout,ResponseTooLarge,RateLimitedor a refused host.
host.crypto
All synchronous. Encodings: "utf8" | "hex" | "base64" | "base64url" (inputs default to utf8; digests are
hex | base64 | base64url). Failures throw BAD_REQUEST: host.crypto.<op>: <reason>; the verify operations return
false / { valid: false } instead of throwing. Budget: 200 operations and 8 MB of input per invocation.
host.crypto.hmacSha256({ key, keyEncoding?, data, dataEncoding?, outputEncoding? }): string
host.crypto.hmacSha1({ key, keyEncoding?, data, dataEncoding?, outputEncoding? }): string
host.crypto.sha256({ data, dataEncoding?, outputEncoding? }): string
host.crypto.sha1({ data, dataEncoding?, outputEncoding? }): string
host.crypto.aesCbcDecrypt({ key, keyEncoding?, iv, ivEncoding?, data, dataEncoding?,
padding?: "pkcs7" | "none", outputEncoding? }): string // 16/24/32-byte key, 16-byte iv
host.crypto.ed25519Verify({ publicKey, publicKeyEncoding?, signature, signatureEncoding?, data, dataEncoding? }): boolean
host.crypto.ed25519Sign({ seed, seedEncoding?, data, dataEncoding?, outputEncoding? }): string
host.crypto.verifyJwtRs256({ token, jwks: { keys: object[] }, issuer: string | string[], audience: string | string[] })
: { valid: true; header: object; payload: object } | { valid: false; reason: string } // 60 s clock skew
host.crypto.timingSafeEqual(a: string, b: string): boolean
host.crypto.base64Encode(text: string): string
host.crypto.base64Decode(base64: string): string
host.crypto.encode({ data: string; from: Encoding; to: Encoding }): string
Verify a webhook signature over the exact bytes the vendor sent:
// infrastructure/plugins/hello-channel/index.ts
const expected = host.crypto.hmacSha256({ key: secret, data: request.rawBodyBase64, dataEncoding: "base64", outputEncoding: "hex" });
if (!host.crypto.timingSafeEqual(signature, expected)) {
return tools.reject("bad signature");
}
Derive a short idempotency key:
// plugins/discord/index.ts
host.crypto.sha256({ data: `${deliveryId}:${index}`, outputEncoding: "hex" }).slice(0, 25);
host.session
Acts on the chat the invocation belongs to. Present when your code derives any agentApi:* permission (or
host:session:sendError); each operation additionally requires its own permission. Operations on "your" session
fail in invocations that have none (for example pluginInstalled).
host.session.get(): Promise<{ id: string; agentId: string; state: string }>
agentApi:session.read — your session, with its effective state.
host.session.conversation.read(args?: { sessionId?: string; limit?: number; cursor?: string })
: Promise<{ messages: ConversationMessage[]; nextCursor: string | null }>
interface ConversationMessage {
id: string; role: string; messageType: string; content: string;
senderSessionId: string | null; // set when another agent's session sent it
senderAgentName: string | null;
channelSender: { displayName: string; handle: string | null; isVerified: boolean; isSharedConversation: boolean } | null;
senderUserName: string | null; // which console user typed it
createdAt: string;
}
agentApi:conversation.read. Newest limit messages first page; pass nextCursor back as cursor for older
pages (null = no more). Text lines only. Passing another sessionId also needs agentApi:session.read.cross;
an unknown or foreign id returns an empty page.
host.session.message.post(args: { text: string }): Promise<{ messageId: string }>
agentApi:message.post — post into your session.
host.session.todo.create(args: { texts: string[] }): Promise<{ todos: { id: string; text: string }[] }> // todo.write
host.session.todo.list(): Promise<{ todos: { id: string; text: string }[] }> // todo.read
host.session.todo.update(args: { todoId: string; text: string }): Promise<{ id: string; text: string }> // todo.write
host.session.todo.complete(args: { todoId: string }): Promise<void> // todo.write — marks done
host.session.todo.delete(args: { todoId: string }): Promise<void> // todo.write — removes
Open todos keep the agent working turn after turn. A missing or foreign todo rejects (404).
host.session.notify.schedule(args: { message: string; delaySeconds: number })
: Promise<{ notifyId: string | null; fireAt: string | null; isCanceled: boolean; cancelReason: string | null }>
agentApi:notify.write — wake this session later with a note. Another plugin's middleware may rewrite, delay or
cancel it; a blanked note comes back { notifyId: null, isCanceled: false }.
host.session.subagent.spawn(args: { targetAgentId?: string; task: string; isFireAndForget?: boolean })
: Promise<{ outcome: "created" | "forbidden" | "depthExceeded"; sessionId: string | null }>
agentApi:subagent.spawn — start a subagent; never blocks. targetAgentId omitted = a copy of the current agent.
isFireAndForget: true = no report expected. Same reach and depth rules as the start_subagent tool.
host.session.peer.send(args: { sessionId: string; text: string })
: Promise<{ isDelivered: boolean; isCanceled: boolean; cancelReason: string | null }>
host.session.peer.get(args: { sessionId: string }): Promise<{ id: string; agentId: string; state: string }>
agentApi:peer.send / agentApi:session.read.cross. send delivers into another visible session in the account
and wakes it (fires interAgentMessage); not delivering is data, not an error. get works without a session of
your own; a closed session is readable but not messageable; a foreign id is "not found".
host.session.sendError?(message: string): Promise<void>
host:session:sendError — report a failure into the invoking session. At most 5 per invocation. Stays available
even when the hop-depth fuse withholds the rest.
Hop-depth fuse
When plugin code sends a message that triggers plugin code that sends a message… each generation is a hop. Past
4 hops from one original event, host.session is absent for that generation (except sendError and
session.llm). Normal use — reacting once to one event — is one hop.
host.agents
Your installation's own agents. Present when you derive any agentApi:* permission; works without a session.
Never reaches an agent your installation didn't create (those are "not found").
host.agents.create(args: {
key: string; name: string; description?: string; modelId?: string; soul?: string; instructions?: string;
isSubagent?: boolean; parentAgentId?: string | null; isGroupOnlyCallable?: boolean;
replyMode?: "implicit" | "auto" | "all"; thinkingLevel?: "off" | "low" | "medium" | "high";
}): Promise<PluginAgent & { wasCreated: boolean }> // agents.write — get-or-create by key
host.agents.list(): Promise<{ agents: PluginAgentListItem[] }> // agents.read — match on `key`
host.agents.update(args: {
agentId: string; name?; description?; modelId?; soul?; instructions?; enabled?: boolean;
isSubagent?: boolean; parent?: { parentAgentId: string | null };
isGroupOnlyCallable?; replyMode?; thinkingLevel?;
}): Promise<PluginAgent> // agents.write — omitted = unchanged
host.agents.delete(args: { agentId: string }): Promise<void> // agents.write — 409 if it's the account default
interface PluginAgent {
id: string; slug: string; name: string; description: string; modelId: string | null;
soul: string; instructions: string; enabled: boolean;
isSubagent: boolean; parentAgentId: string | null; isGroupOnlyCallable: boolean;
replyMode: "implicit" | "auto" | "all"; thinkingLevel: "off" | "low" | "medium" | "high";
}
interface PluginAgentListItem {
id: string; slug: string; name: string; description: string; modelId: string | null; enabled: boolean;
key: string | null; isSubagent: boolean; parentAgentId: string | null; isGroupOnlyCallable: boolean;
}
Guide: Agent crews.
host.models
host.models.list(): Promise<{ models: {
id: string; modelIdentifier: string; displayName: string;
inputUsdPer1M: number | null; outputUsdPer1M: number | null; cachedUsdPer1M: number | null;
}[] }>
agentApi:models.read — the account's enabled models and the prices the user set. Pass id as modelId to
host.agents.create/update.
host.workspace
The files of the agent the invocation runs for — the same files and rules as the agent's own workspace_* tools
(including the account's shared area). Present with host:workspace:read and/or host:workspace:write and an
agent in context: tools, and the systemPrompt, interAgentMessage (the sender), sshCommandSending,
sshCommandCompleted, cronJobFiring and cronJobCompleted hooks.
host.workspace.list(args?: { dir?: string })
: Promise<{ path: string; entries: { name: string; dir: boolean; sizeBytes: number; modified: string;
contentHash: string | null }[]; truncated: boolean }> // read
host.workspace.read(args: { path: string })
: Promise<{ path: string; binary: boolean; tooLarge: boolean; sizeBytes: number;
contentHash: string; text: string | null; totalLines: number }> // read
host.workspace.write(args: { path: string; content: string })
: Promise<{ path: string; sizeBytes: number; contentHash: string }> // write
host.workspace.edit(args: {
path: string;
operation: "replace_lines" | "insert_at_line" | "str_replace";
expectedHash?: string; // refuse if the file changed since you read it
fromLine?: number; toLine?: number; // replace_lines
line?: number; // insert_at_line
content?: string; // replace_lines / insert_at_line
oldText?: string; newText?: string; // str_replace
}): Promise<{ path: string; sizeBytes: number; contentHash: string }> // write
host.workspace.delete(args: { path: string }): Promise<{ deleted: true }> // write
write stores UTF-8 text. Errors carry codes such as invalid_path, file_not_found, bad_arguments and
workspace_storage_exceeded. (The downloadable SDK types list these arguments as untyped objects; the shapes above
are what the platform accepts.)
host.media
Upload audio and have the platform transcode it. Requires host:media:transcode, which also raises the
invocation's limits (wall clock at least 60 s, more memory).
host.media.uploadAudio(args: { contentType: string; dataBase64: string }): Promise<{ uploadToken: string }>
host.media.transcodeAudio(args: { uploadToken: string }): Promise<{ downloadUrl?: string; /* … */ }>
One upload at a time: call transcodeAudio before the next uploadAudio. The returned downloadUrl can be
fetched once with host.fetch. 10 media operations per invocation.
Models from plugin code
Three surfaces let tools, middleware, lifecycle events and compaction use a model without an API key. None needs a permission; usage is metered to the account. None takes a model argument — the platform decides which model runs. Model-provider plugins don't get them.
host.session.llm?.complete(args: LlmCompletionArgs): Promise<LlmCompletion> // the chat's own agent model
host.llm?.complete(args: LlmCompletionArgs): Promise<LlmCompletion> // the account's Utility model
interface LlmCompletionArgs {
messages: { role: string; content: string }[];
params?: { temperature?: number; maxTokens?: number; topP?: number; stop?: string[];
thinking?: "off" | "low" | "medium" | "high" };
}
interface LlmCompletion {
schemaVersion: number;
assistant: { text: string | null; toolCalls: { id: string; name: string; argumentsJson: string }[];
thinking?: { text: string | null; replayJson: string | null } };
usage: { inputTokens: number; outputTokens: number; cachedTokens: number };
stopReason: string;
}
host.session.llm— present whenever the invocation has a chat and agent, even without any otherhost.sessionmember. 5 calls per invocation.host.llm— present only when the account has a Utility model set (AI Providers page). No fallback to the agent's model. 5 calls per invocation, counted separately.
host.decide.noul({ state, questions: { instructions: string; trueMeans?: string; falseMeans?: string }[] })
host.decide.choose({ state, questions: { instructions: string; options: Record<string, string | null> }[] })
host.decide.score({ state, questions: { instructions: string; levels: string[] }[] })
: Promise<{ answeredBy: "systemOne" | "llmFallback"; fallbackReason?: string; answers: … }>
Fast, typed decisions on the account's (or agent's) Decision model. state is a string, object or array read
once for all questions; answers come back in the same order: { probability }, { choice, confidence?, probabilities? }, { score, confidence?, probabilities? }. With no decision model, a chat model answers instead
(answeredBy: "llmFallback", slower, no calibrated confidence). If nothing can answer, the call throws
not_configured: …. No per-invocation cap.
host.ssh
host.ssh.command(command: string, options?: { timeoutSeconds?: number })
: Promise<{ exitCode: number; output: string; errorOutput: string; isTruncated: boolean; durationMs: number }>
Only inside sshConnectionOpening, sshCommandSending and sshCommandCompleted, only on the server that hook
fired for. host:ssh:command. 60 s default, 90 s max, 10 calls per invocation, stateless. A non-zero exitCode is
a normal result; an unreachable host rejects. See Middleware.
host.connection
host.connection.send(frames: ({ link?: string; text: string } | { link?: string; base64: string })[],
options?: { idempotencyKey?: string }): Promise<void>
Write frames onto a live connection your plugin owns, from a channel handler of that connection's channel.
host:connection:send. At most 20 frames per call, all accepted or all refused; 20 calls per invocation. Reuse the
same idempotencyKey on a retry. See Live connections.