# Host API reference

`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](plugin-permissions.md)).

**Presence and permission.** `host.workspace`, `host.media`, `host.ssh`, `host.connection`, `host.llm` and
`host.session.llm` are `undefined` when you weren't granted them or the invocation has no context for them. The
platform-API members (`host.session`, `host.agents`, `host.models`, `host.cronJobs`, `host.plugins`, `host.license`) are present in
tool, middleware, lifecycle, compaction and web-provider runs, and each call needs its permission or is rejected with
a 403. 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"`. Failures of `host.session`,
`host.agents`, `host.models`, `host.cronJobs`, `host.plugins` and `host.license` also carry the HTTP status as a numeric
`error.status`.

| Member | Needs | Section |
| --- | --- | --- |
| `host.log` | — | [log](#hostlog) |
| `host.settings` | — | [settings](#hostsettings) |
| `host.fetch` | `manifest.egress` | [fetch](#hostfetch) |
| `host.oauth` | — | [oauth](#hostoauth) |
| `host.crypto` | — | [crypto](#hostcrypto) |
| `host.license` | — | [license](#hostlicense) |
| `host.session` | an `agentApi:*` permission per operation | [session](#hostsession) |
| `host.agents` | `agentApi:agents.*` | [agents](#hostagents) |
| `host.cronJobs` | `agentApi:cronJobs.*` | [cronJobs](#hostcronjobs) |
| `host.plugins` | `agentApi:plugins.*` | [plugins](#hostplugins) |
| `host.models` | `agentApi:models.read` | [models](#hostmodels) |
| `host.workspace` | `host:workspace:*` + an agent in context | [workspace](#hostworkspace) |
| `host.media` | `host:media:transcode` | [media](#hostmedia) |
| `host.session.llm`, `host.llm`, `host.decide` | context | [models from plugin code](#models-from-plugin-code) |
| `host.ssh` | `host:ssh:command`, SSH hooks only | [ssh](#hostssh) |
| `host.connection` | `host:connection:send`, a live connection you own | [connection](#hostconnection) |

---

## `host.log`

```ts
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`

```ts
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 with no saved value and no `default`, or one you
  didn't declare, returns `null`.
- `getSecret` returns a `secret` field's value. It is fetched only when you call it. It throws
  `MissingCredential: …` for a key you didn't declare or the user hasn't set.
- In a [model provider](plugin-model-providers.md) handler these read the provider the user added; in a channel
  handler, `scope: "channel"` fields read that channel's values.

## `host.fetch`

```ts
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)
  oauth?: string;                           // a manifest.oauth key: the platform adds the Bearer token
  account?: string;                         // host.oauth.accounts() id; required when several are connected
}): Promise<{ status: number; headers: Record<string, string>; body: string }>
```

- Only hosts in `manifest.egress` or a `hosts` setting ([rules](plugin-manifest.md#egress)).
- 60 requests per minute per installation, 10 s timeout, 5 MB response.
- Any HTTP status resolves — check `status`. Redirects are followed (at most 5), and every hop must also be an
  allowed host; with `oauth` they are not followed and a 3xx comes back to you.
- The call rejects with an error whose `name` says why, so you can branch on it:

  | `error.name` | When | `error.message` |
  | --- | --- | --- |
  | `EgressDenied` | the host is refused, or is a private, loopback, link-local or metadata address | `host not allowlisted: <url>` or `blocked address for <host>` |
  | `RateLimited` | over the rate limit | `fetch rate limit exceeded` |
  | `FetchTimeout` | it timed out | `fetch timed out` |
  | `ResponseTooLarge` | the response is too big | `response exceeded <n> bytes` |
  | `TooManyRedirects` | more than 5 redirects | `more than 5 redirects` |
  | `Internal` | anything else went wrong with the request | `fetch failed: <reason>` |
- With `oauth`, the URL host must be in that key's `apiHosts`, any `Authorization` header you set is replaced, and a
  401 triggers one token refresh and retry. You never see the token. Errors have `err.name` of `OAuthNotConnected`,
  `OAuthAccountRequired`, `OAuthReconnectRequired` or `OAuthUnavailable`.

## `host.oauth`

```ts
host.oauth.accounts(key?: string)
  : Promise<{ id: string; key: string; label: string | null; scopes: string[]; status: "connected" | "needsReconnect" }[]>
```

Always present. The sign-ins the customer connected for this installation, for one `manifest.oauth` key or all of
them; `[]` when none. Pass an `id` as `account` to `host.fetch` when more than one is connected for a key.

## `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.

```ts
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.rsaSha256Sign({ privateKey, data, dataEncoding?, outputEncoding? }): string
  // RSASSA-PKCS1-v1_5 + SHA-256 (JWT "RS256"). privateKey: PEM, PKCS#1 or PKCS#8, line breaks optional. At most 10 per run.
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:

```ts
// 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");
}
```

Sign a GitHub App JWT (RS256) with the app's private key:

```ts
const b64 = (value: object) => host.crypto.encode({ data: JSON.stringify(value), from: "utf8", to: "base64url" });
const now = Math.floor(Date.now() / 1000);
const unsigned = `${b64({ alg: "RS256", typ: "JWT" })}.${b64({ iss: appId, iat: now - 60, exp: now + 540 })}`;
const signature = host.crypto.rsaSha256Sign({ privateKey: await host.settings.getSecret("privateKey"), data: unsigned, outputEncoding: "base64url" });
const jwt = `${unsigned}.${signature}`;
```

Derive a short idempotency key:

```ts
// 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 in tool, middleware, lifecycle, compaction and web-provider
runs; each operation requires its own permission (a call to one that wasn't derived rejects with a 403).
`sendError` needs `host:session:sendError` and is present only with that. Operations on "your" session fail in
invocations that have none (for example `pluginInstalled`).

```ts
host.session.get(): Promise<{ id: string; agentId: string; state: string }>
```
`agentApi:session.read` — your session, with its effective state.

```ts
host.session.channelChat(): Promise<{ chat: { channelId: string; chatId: string; targetAgentId: string | null } | null }>
```
`agentApi:session.read` — the channel chat your session belongs to (any channel kind), or `chat: null` for a console
session or one with no chat. `targetAgentId` is the agent the channel points at, `null` if it points at none. The ids
are the ones `list_channels` and `host.cronJobs` `deliverTo` use.

```ts
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.

```ts
host.session.message.post(args: { text: string }): Promise<{ messageId: string }>
```
`agentApi:message.post` — post into your session.

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

```ts
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 }`.

```ts
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.

```ts
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".

```ts
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, the platform-API members (`host.session` operations, `host.agents`, `host.models`,
`host.cronJobs`, `host.license`) are absent for that generation; `host.session.sendError` and `host.session.llm`
stay. Normal use — reacting once to one event — is one hop.

---

## `host.agents`

Your installation's own agents. Each operation needs `agentApi:agents.read` or `agentApi:agents.write`; works
without a session. Never reaches an agent your installation didn't create (those are "not found").

```ts
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";
  tools?: string[];
}): Promise<PluginAgent & { wasCreated: boolean }>                         // agents.write — get-or-create by key (key 1..200 chars)

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?; tools?: string[];
}): 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";
  unresolvedTools: string[];             // names in `tools` that matched no installed tool
}
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;
}
```

`tools` limits the agent to exactly those tools: a built-in by bare name (`web_search`), your own tool by bare name,
another plugin's tool by its full `slug__tool` name. Passing it makes the agent's tool list **managed by your
plugin**: the console shows it read-only, the user can no longer change it, and every `update({ tools })` replaces
the whole list. `tools: []` means no tools. Names that match nothing come back in `unresolvedTools` and are not
granted; re-apply `tools` in your setup on `sessionStarted` and `pluginUpdated`. On a `create` whose key already
exists, `tools` is ignored like every other field. Omit `tools` on `update` to leave the list alone.

Guide: [Agent crews](plugin-agent-crews.md).

## `host.cronJobs`

Schedules your installation owns, addressed by your own `key` (`^[a-z0-9][a-z0-9_-]{0,63}$`).

```ts
host.cronJobs.create(args: {
  key: string; agentId: string; name: string; prompt: string;
  cron?: string; eventSourceId?: string;                  // exactly one
  deliverTo?: { channelId: string; personId?: string; chatId?: string };
  enabled?: boolean;
}): Promise<PluginCronJob & { wasCreated: boolean }>                       // cronJobs.write — get-or-create by key

host.cronJobs.list(): Promise<{ cronJobs: PluginCronJob[] }>               // cronJobs.read
host.cronJobs.update(args: { key: string; name?: string; prompt?: string; cron?: string;
  deliverTo?: { channelId?: string; personId?: string; chatId?: string }; enabled?: boolean }): Promise<PluginCronJob>  // cronJobs.write
host.cronJobs.delete(args: { key: string }): Promise<void>                 // cronJobs.write — an unknown key succeeds
host.cronJobs.listEventSources(): Promise<{ eventSources: { id: string; pluginSlug: string; pluginName: string; enabled: boolean }[] }>  // cronJobs.read

interface PluginCronJob {
  id: string; key: string; agentId: string; name: string; prompt: string;
  cron: string | null;                 // 5 fields, in the account's timezone; null on an On event schedule
  eventSourceId: string | null;        // the live connection that triggers an On event schedule
  enabled: boolean;
  deliverTo: { channelId: string; personId: string | null; chatId: string | null } | null;
  nextRunAt: string | null;
}
```

An existing key comes back untouched with `wasCreated: false`. `agentId` must be an agent your installation created.
`deliverTo` must be a person or chat that agent can already reach (the ids `list_channels` shows), otherwise the call
rejects with 400. It rejects with 402 when the plan has no room or the cron is denser than the plan allows. `update`
changes only what you pass; `deliverTo: {}` stops delivering, and `cron` is refused on an On event schedule. A
schedule is deleted with its agent. The console shows it as "From <your plugin>" and the user may pause, edit or
delete it, so don't re-create a deleted one on every `sessionStarted`.

## `host.plugins`

Build and release the plugins your account owns, from files in the calling agent's workspace. It is meant for a
plugin like Plugin Studio. Every call works on a plugin the account created, by id; an unknown, foreign or official id
answers `plugin_not_found`. It is present in tool runs. Middleware and lifecycle runs may read, but a write answers
`needs_tool_call`, because only a tool run knows who started the turn.

```ts
host.plugins.list(): Promise<{ plugins: AuthoredPluginSummary[] }>                          // plugins.read
host.plugins.get(args: { pluginId: string }): Promise<AuthoredPluginInfo | PluginActionError>   // plugins.read — no file contents
host.plugins.logs(args: { pluginId: string; levels?: string[]; since?: string; search?: string;
  sessionId?: string; beforeCursor?: string; limit?: number }): Promise<{ logs: PluginLogLine[]; nextCursor: string | null } | PluginActionError>  // plugins.read

host.plugins.create(args: { slug: string; name: string; description?: string }): Promise<{ pluginId: string; slug: string; url: string; alreadyExisted: boolean } | PluginActionError>  // plugins.write
host.plugins.checkout(args: { pluginId: string; versionId?: string; path: string; overwrite?: boolean }): Promise<PluginCheckoutResult | PluginActionError>  // plugins.write
host.plugins.saveDraft(args: { pluginId: string; workspacePath: string }): Promise<PluginDraftResult | PluginActionError>   // plugins.write
host.plugins.install(args: { pluginId: string; follow: "draft" | "latest"; agentIds?: string[] }): Promise<PluginInstallResult | AwaitingConfirmation | PluginActionError>  // plugins.write
host.plugins.acceptUpdate(args: { pluginId: string; versionId: string }): Promise<AwaitingConfirmation | PluginActionError>  // plugins.write
host.plugins.configure(args: { pluginId: string; settings: Record<string, unknown> }): Promise<{ url: string } | AwaitingConfirmation | PluginActionError>  // plugins.write
host.plugins.uninstall(args: { pluginId: string }): Promise<AwaitingConfirmation | PluginActionError>  // plugins.write

host.plugins.publish(args: { pluginId: string; draftVersionId: string }): Promise<AwaitingConfirmation | PluginActionError>  // plugins.release
host.plugins.yank(args: { pluginId: string; versionId: string }): Promise<AwaitingConfirmation | PluginActionError>         // plugins.release
host.plugins.addToBrowse(args: { pluginId: string }): Promise<AwaitingConfirmation | PluginActionError>                     // plugins.release
host.plugins.removeFromBrowse(args: { pluginId: string; deprecationMessage?: string }): Promise<AwaitingConfirmation | PluginActionError>  // plugins.release
host.plugins.setPrice(args: { pluginId: string; price: { model: "OneTime" | "Monthly"; amountUsd: number } | null; payoutCountry?: string }): Promise<AwaitingConfirmation | PluginActionError>  // plugins.release
host.plugins.delete(args: { pluginId: string }): Promise<AwaitingConfirmation | PluginActionError>                           // plugins.release

type PluginActionError = { error: string; message: string };
type AwaitingConfirmation = { status: "awaiting_confirmation"; summary: string; note: string };
```

Paths are workspace paths: a folder or a `.zip`, with the `shared/` prefix for the shared workspace. `saveDraft` reads the
package itself in chunks, so bytes never pass through the model or the sandbox; a build error comes back as
`{ error, message }`. Saving the same content again returns the existing draft with `unchanged: true`. `publish` takes a
draft id: the person approves that exact draft, and publishing promotes it without building again.

**Results, not rejections.** A refusal or build error answers HTTP 200 with `{ error, message }`, so the message always
reaches you (a bridge error would drop the body). A missing permission is still a 403. Codes include `plugin_not_found`,
`package_not_found`, `draft_not_found`, `package_invalid`, `build_failed`, `setup_off`, `needs_trusted_turn`,
`setup_needs_account_user` and `setup_needs_direct_chat`.

**Asking for a person's OK.** Each `plugins.release` call, and the `plugins.write` calls that widen access, answer
`awaiting_confirmation` and change nothing yet. See [Asking for a person's OK](plugin-permissions.md#asking-for-a-persons-ok).

A tool that holds `agentApi:plugins.write` may run for up to 120 seconds instead of 30; middleware and lifecycle runs keep their own limits. One draft save per account runs at a time; a second answers `busy_try_again`. The route is rate limited per web pod (4 at once, 120 a minute), so the account-wide ceiling is that times the number of web pods.

**Who can change things.** A change works only in a direct chat with a user of the account, or in a scheduled run in its own chat with nothing from outside in the turn. A reminder from `notify_session_in` counts as outside input, and a schedule pointed at a shared chat is refused. A subagent, a peer-started turn and a platform-started turn answer `setup_needs_account_user`: report the ids to the agent that started you. A turn that includes a message from someone who is not a user of the account answers `needs_trusted_turn`, and a group chat `setup_needs_direct_chat`. `host.plugins.logs` follows the same rule, because log lines can hold other sessions' data.

## `host.models`

```ts
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.license`

```ts
host.license(): Promise<{ model: "OneTime" | "Monthly"; status: "Active" | "Cancelling" | "PastDue" | "Ended";
                          paidThrough: string | null } | null>
```

This installation's own licence, read-only, no permission needed. `null` means nothing to pay: the plugin is free, the
account owns it, or it was installed while the plugin was free. `paidThrough` is an ISO timestamp (monthly only).

---

## `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, every middleware hook (for `interAgentMessage` it is the sender) and the session lifecycle
events.

```ts
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.read(args: { path: string; encoding: "base64"; offset?: number; length?: number })
  : Promise<{ path: string; sizeBytes: number; contentHash: string; offset: number; base64: string }> // read

host.workspace.write(args: { path: string; content: string; encoding?: "utf8" | "base64" })
  : 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, or the decoded bytes when `encoding` is `"base64"` (invalid base64 is `bad_arguments`).
A base64 `read` returns the byte range starting at `offset` (default 0), at most 2 MiB per call (`length` is clamped to
that; read a larger file in chunks, using `sizeBytes` and `contentHash` to know when you are done and that the file did
not change). An `offset` past the end gives an empty `base64`. Errors carry codes such as `invalid_path`,
`file_not_found`, `bad_arguments` and `workspace_storage_exceeded`. 

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

```ts
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, compaction and web providers 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.

```ts
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.

```ts
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`

```ts
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](plugin-middleware.md#hostsshcommand--run-your-own-command-on-that-server).

## `host.connection`

```ts
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](plugin-live-connections.md#sending-outside-a-step--hostconnectionsend).
