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>
  • get returns a non-secret setting's value (its default until the user saves one). A hosts field returns a JSON array encoded as a string — JSON.parse it. A key you didn't declare throws.
  • getSecret returns a secret field'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.egress or a hosts setting (rules).
  • 60 requests per minute per installation, 10 s timeout, 5 MB response.
  • Any HTTP status resolves — check status. Rejects with FetchTimeout, ResponseTooLarge, RateLimited or 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 other host.session member. 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.