@agentserve/app-sdk is how an app talks to its workspace. Every call goes from the app's sandboxed frame to the page around it, which checks the call and runs it as the person using the app, narrowed to the app's manifest. The app has no other way out: it cannot make network requests of its own.
Install
agentserve app init copies the SDK into your app at vendor/app-sdk, and the template's package.json depends on it there:
"dependencies": {
"@agentserve/app-sdk": "file:./vendor/app-sdk"
}Run bun install (or npm install) once after init. The SDK has three entry points:
| Import | What it holds |
|---|---|
@agentserve/app-sdk | AppClient, getAppClient, AppBridgeError and the result types |
@agentserve/app-sdk/react | React hooks and one-call functions (React 19) |
@agentserve/app-sdk/protocol | The bridge protocol, its limits and schemas |
React
Read hooks load when the component mounts and again when their inputs change. Each returns the same shape:
interface AppQueryState<T> {
data: T | undefined // the latest answer; undefined until it arrives, or after an error
error: AppBridgeError | Error | undefined
loading: boolean // true while a load is in flight, including a refetch
refetch: () => void // load again with the same inputs, for example after a write
}An answer that arrives after a newer request, or after the component unmounts, is ignored.
import { useTable, writeRows } from '@agentserve/app-sdk/react'
export function Items() {
const items = useTable('items', 50)
async function add(title: string) {
await writeRows('items', [{ title, done: false }])
items.refetch()
}
if (items.loading) return <p>Loading…</p>
if (items.error) return <p>{items.error.message}</p>
return (
<ul>
{items.data?.rows.map((row) => (
<li key={row.id}>{String(row.data.title)}</li>
))}
</ul>
)
}Hooks
| Hook | Returns | Needs |
|---|---|---|
useViewer() | The person using the app, their role, and the app's identity. See Viewer. | Nothing |
useTables() | { tables }: the tables the manifest names that the person can read, with their columns. | permissions.tables |
useQuery(params) | { rows, nextCursor } for a table query. Reloads when params changes by value. | The table in permissions.tables |
useTable(table, limit = 200) | The first limit rows of a table. | The table in permissions.tables |
useModel() | The semantic model's entities, attributes and measures. | model: read |
useModelQuery(params) | Rows of a model query. Reloads when params changes by value. | model: read |
useKnowledgeSearch(params) | { results } of a knowledge search. Reloads when params changes by value; pass null to wait. | The knowledge bases in permissions.knowledge |
useCompletion() | { run, cancel, text, data, error, loading } for a streamed completion: text grows as the model writes, and cancel() stops it. | The model in permissions.ai.models |
useFiles(params) | { folders, files, nextCursor }: a page of the workspace files the person can see. Reloads when params changes by value. | files: read or write |
useSettings() | { values, secretsSet }: the install's settings. | Nothing |
useLaunch() | What the page was opened for by a Chat command or an action, or null. See Plugins. | Nothing |
Functions
These run once and return a promise. Use them for writes, and for reads you trigger yourself.
| Function | Resolves to | Needs |
|---|---|---|
writeRows(table, rows) | { rows }: the rows added | The table in permissions.tables |
runWorkflow(workflow, input?) | The run's result, after it finishes | The workflow in permissions.workflows |
queueWorkflow(workflow, input?) | { executionId, status: 'queued' }, once queued | The workflow in permissions.workflows |
assignRelayTask({ relay, title, objective, acceptance? }) | { taskId, relayId } | The Relay in permissions.relays |
askChat(prompt) | { opened: true } | chat: ask |
readFile(fileId) | The file and its content | files: read or write |
writeFile({ name, content, encoding?, contentType? }) | { id, name, contentType, size } | files: write |
readModel() | The semantic model | model: read |
queryModel(params) | Rows of a model query | model: read |
complete(params) | { model, content, tokens, costUsd } | The model in permissions.ai.models |
streamComplete(params, onDelta) | The same, after handing each piece of the answer to onDelta | The model in permissions.ai.models |
generateMedia({ kind, prompt, … }) | { kind, model, mimeType, encoding: 'base64', content, costUsd } | The kind in permissions.ai.media |
answerChat(prompt) | { content, costUsd } | chat: answer |
searchKnowledge(params) | { results } | The knowledge bases in permissions.knowledge |
runIntegration({ action, input?, account? }) | { status, output, error } | The action in permissions.integrations |
callMcp({ connector, tool, arguments? }) | { status, text, content, error } | The connector in connectors |
listFiles(params?) | { folders, files, nextCursor } | files: read or write |
pickFiles({ multiple?, types?, title? }) | { files }: what the person chose in Agent Serve's file picker | files: read or write |
copyText(text) | { copied: true }, once the text is on the person's clipboard | Nothing |
download({ filename, content, encoding?, mimeType? }) or download({ fileId, filename? }) | { started: true }, once the browser saves the file | Nothing; files: read or write for a fileId |
complete, streamComplete and answerChat take a last { signal } argument: aborting the AbortSignal cancels the call (see Cancelling).
AppClient
The React functions use one shared client. Outside React, or for anything the hooks do not cover, use it directly:
import { AppBridgeError, getAppClient } from '@agentserve/app-sdk'
const client = getAppClient()
const viewer = await client.getViewer()getAppClient() creates the page's client on first use. new AppClient({ timeoutMs }) creates another; it only works inside an app frame.
| Method | Bridge method |
|---|---|
getViewer() | viewer.get |
listTables() | tables.list |
query(params) | tables.query |
writeRows(table, rows) | tables.insertRows |
runWorkflow(workflow, input?) | workflows.run |
queueWorkflow(workflow, input?) | workflows.run with async: true |
assignRelayTask(params) | relays.assignTask |
readFile(fileId) | files.read |
writeFile(params) | files.write |
readModel() | model.read |
queryModel(params) | model.query |
listTools() | tools.list |
runTool(name, input?) | tools.run |
complete(params) | ai.complete |
streamComplete(params, onDelta) | ai.complete with stream: true |
generateMedia(params) | ai.media |
answerChat(prompt) | chat.answer |
searchKnowledge(params) | knowledge.search |
runIntegration(params) | integrations.run |
getSettings() | settings.get |
callMcp(params) | mcp.call |
getLaunch() | launch.get |
listFiles(params?) | files.list |
pickFiles(params?) | files.pick |
copyText(text) | ui.copy |
download(params) | ui.download |
askChat(prompt) | chat.ask |
resize(height) | ui.resize |
onTheme(listener) | Theme events; returns an unsubscribe function |
call(method, params) | Any bridge method, typed by method name |
close() | Stops listening and fails every call still waiting |
A call that gets no answer within 60 seconds fails with timeout; model, media, Chat answer and integration calls wait up to 300 seconds. Pass timeoutMs to a new client to change it.
Methods in detail
Viewer
getViewer() / useViewer() returns:
| Field | Type |
|---|---|
userId, name, email | The person using the app. name and email may be null. |
role | Their workspace role: read, write or admin. |
workspaceId | The workspace the app is installed in. |
app | { id, installId, versionId, version, slug } of the running install. |
permissions | The installed version's permissions, with defaults filled in. Slot names appear in the list of their kind. |
slots | Each slot the manifest declares, by name: { kind, title, bound, model }. bound says whether it is set up; model is the bound model id for a model slot, and null otherwise. A bound table's or knowledge base's own name is not shown, since the person may not be able to see it. |
Call methods with a slot's name; the server reaches the resource the admin bound. tables.list returns each table's slot (its slot name, or null), so a page can find the table a slot is bound to. Under agentserve app dev, slots resolve from .agentserve/slots.json in the app folder — { "pipeline": "Deals" }, slot name to the workspace resource's name — else from each slot's default.
Tables
query(params) / useQuery(params) takes:
| Param | Rules |
|---|---|
table | A table name listed in permissions.tables. |
where | Optional. Column name to value; every condition must hold. A value is a string (up to 10,000 characters), number, boolean, or null for an empty cell. At most 20 conditions. |
sort | Optional. Up to 5 of { column, direction }, where direction is asc or desc. |
columns | Optional. Up to 200 column names to return. |
limit | Optional. 1–500 rows, default 100. |
cursor | Optional. The nextCursor of the previous page. |
It returns { rows, nextCursor }. Each row is { id, data }, with data keyed by column name. nextCursor is null on the last page.
writeRows(table, rows) adds 1–100 rows per call, each keyed by column name with at most 200 columns. It returns the added rows. The person must be allowed to add rows to the table.
Every row, column and row-security rule that applies to the person applies to the app too.
Workflows
runWorkflow(name, input) runs a deployed workflow and waits for it. It resolves to { executionId, status, output, error }, where status is completed, failed, paused or cancelled. A run that starts but fails still resolves, with status: 'failed' and the error message; the call itself rejects only when the run cannot start.
queueWorkflow(name, input) queues the run and resolves with status: 'queued' and the execution id, without output. Both count against the app's budget.
Relays
await assignRelayTask({
relay: 'Field follow-up',
title: 'Follow up with Acme',
objective: 'Send Acme the pricing sheet and book a call next week.',
acceptance: 'A call is booked.',
})title is 1–200 characters, objective 1–8,000, acceptance up to 4,000. The task is assigned as the person using the app, and its cost counts against the app's budget.
Chat
askChat(prompt) opens Chat in the workspace with the prompt filled in. The person reads it and decides whether to send it. The prompt is 1–4,000 characters, and an app can open Chat at most once every 5 seconds.
answerChat(prompt) asks Chat and returns its answer to the app, without opening Chat; it needs chat: answer. Chat answers as the person, from a read-only turn: it can look things up across the workspace, but any tool that would change something is refused. The conversation is saved to the person's Chat list under the app's name, so they can see what was asked for them. The turn is billed like any Chat turn and also counts against the app's budget.
const { content } = await answerChat('Which accounts renewed late this quarter, and why?')AI models
complete(params) sends a conversation to a chat model the manifest lists in ai.models and resolves with the answer:
const { content, costUsd } = await complete({
model: 'gpt-5.5',
messages: [
{ role: 'system', content: 'You write short, friendly product descriptions.' },
{ role: 'user', content: `Describe: ${product.name}` },
],
maxTokens: 300,
})| Param | Rules |
|---|---|
model | A model id listed in permissions.ai.models. |
messages | 1–50 of { role, content }, where role is system, user or assistant; 200,000 characters in all. |
maxTokens | Optional. 1–8,192. |
temperature | Optional. 0–2. |
responseFormat | Optional. json asks for a single JSON object; parse content yourself. Defaults to text. |
streamComplete(params, onDelta) and useCompletion() deliver the answer in pieces as the model writes it, then resolve with the whole answer and its cost:
import { useCompletion } from '@agentserve/app-sdk/react'
function Summary({ notes }: { notes: string }) {
const { run, text, loading } = useCompletion()
return (
<div>
<button
disabled={loading}
onClick={() =>
run({ model: 'gpt-5.5', messages: [{ role: 'user', content: `Summarize: ${notes}` }] })
}
>
Summarize
</button>
<p>{text}</p>
</div>
)
}The person's permission group still decides which providers and models they may use, and a model the deployment blocks is refused with forbidden. A call runs on the workspace's own provider key when it has one, and on Agent Serve's hosted key otherwise; costUsd is what it was billed, and it counts against the app's budget.
generateMedia({ kind, prompt, model?, aspectRatio?, voice? }) makes an image or a clip of speech; kind must be listed in ai.media. Without model the workspace's default model for the kind is used; a model must also be listed in ai.models. The result is base64 content with its MIME type, up to 8 MB:
const image = await generateMedia({ kind: 'image', prompt: 'A lighthouse at dawn', aspectRatio: '16:9' })
setSrc(`data:${image.mimeType};base64,${image.content}`)Knowledge
searchKnowledge({ knowledgeBases, query, topK? }) / useKnowledgeSearch(params) searches 1–5 knowledge bases the manifest names, by name, and returns up to topK passages (1–20, default 5), best match first. Each result is { knowledgeBase, documentId, documentName, sourceUrl, content, similarity }. Only documents the person can read are searched.
const { results } = await searchKnowledge({ knowledgeBases: ['Product docs'], query: 'refund policy' })Integrations
runIntegration({ action, input, account }) runs an integration action the manifest lists in integrations, such as slack_send_message, with the person's own connected account. The app never sees the account's credentials. When the person has several accounts for the provider, pass account with the one's name; otherwise it is chosen for them.
An action that changes anything (sending, creating, updating, deleting, paying) is first put to the person in the workspace page. If they approve, it runs; if not, the call fails with declined. Reads run straight away.
const run = await runIntegration({
action: 'slack_send_message',
input: { channel: '#sales', text: `New lead: ${lead.name}` },
})
if (run.status === 'failed') showNotice(run.error)It resolves with { status, output, error }: status is failed when the provider refused the action. The action's input is the integration's own, the same parameters the tool takes in a workflow.
Under agentserve app dev, searchKnowledge runs against the workspace with your own access, while model, media, Chat answer and integration calls fail with not_found because they are metered to an installed app: give your code a fallback, or deploy and open the app in the workspace.
Files
readFile(fileId) returns { id, name, contentType, size, encoding, content }. Text files (text, JSON, XML, CSV, YAML, Markdown) come back as utf8; anything else as base64. Files up to 5 MB can be read. A file that may contain secret values is refused with forbidden.
writeFile({ name, content, encoding, contentType }) creates a new workspace file. encoding is utf8 (the default) or base64, for binary content such as a photo. A file is at most 5 MB. If the name is taken, the new file gets a numbered suffix.
listFiles({ folder?, search?, types?, recursive?, limit?, cursor? }) / useFiles(params) lists the workspace files and folders the person can see, so an app can build its own file browser. It needs files: read or write, and returns exactly what the person could see in Files: their file and folder access applies.
| Param | Rules |
|---|---|
folder | Optional. A folder path such as Contracts/2026; the workspace root when left out. |
search | Optional. Words in the file name, 1–200 characters. |
types | Optional. Up to 20 extensions (.pdf) or media types (application/pdf, image/*). |
recursive | Optional. Include files in subfolders. Defaults to true with a search, false without. |
limit | Optional. 1–100 files, default 50. |
cursor | Optional. The nextCursor of the previous page; it works only with the same folder, search and types. |
folders (the folder's subfolders, or folders whose name matches the search) comes with the first page only. Files come in name order as { id, name, contentType, size, folderPath, updatedAt }. Types are matched after a page is read, so a page filtered by type can hold fewer than limit files while nextCursor still leads on.
pickFiles({ multiple?, types?, title? }) is usually the better choice: Agent Serve shows its own file picker over the app, the person chooses, and the app receives only the files they chose ({ id, name, contentType, size, folderPath, updatedAt }). Files outside types are shown but cannot be chosen. Closing the picker rejects with declined. It needs files: read or write; read a chosen file's content with readFile.
const { files } = await pickFiles({ types: ['.pdf', '.docx'], title: 'Choose a contract' })
const contract = await readFile(files[0].id)Clipboard and downloads
An app runs in a sandboxed frame with no clipboard and no downloads of its own. Agent Serve does both for it, from the host page.
copyText(text) puts up to 100,000 characters on the person's clipboard and shows a short "Copied" notice. Call it from a click: browsers copy only right after the person does something.
download(params) saves a file to the person's computer and shows a "Saved" notice:
{ filename, content, encoding?, mimeType? }saves content the app made.encodingisutf8(the default) orbase64. The file is at most 10 MB.{ fileId, filename? }saves a workspace file the person can read; it needsfiles: readorwriteand is at most 5 MB, thereadFilelimit.
Text, CSV, JSON, XML, Markdown, PDF, images, audio and video, Office documents (.xlsx, .docx, .pptx) and zip archives are saved as they are. Pages and scripts — HTML, SVG, JavaScript and executables — are saved as plain text with .txt added, so a downloaded file never runs when opened. Any other type is refused with invalid_request. The name loses any folder path and characters a file system refuses.
Why not let the frame copy directly? The frame has an opaque origin and runs whatever code the app ships, so granting it clipboard-write would let any script in it write the clipboard at any moment, with no way to tie it to a person's click on Agent Serve's side. Copying through the host page keeps every write capped, visible ("Copied") and on Agent Serve's terms, and the same holds for downloads.
Cancelling
complete, streamComplete and answerChat accept { signal }, and useCompletion() returns cancel(). Aborting stops the call and rejects it with cancelled.
const controller = new AbortController()
stopButton.onclick = () => controller.abort()
await streamComplete(params, (delta) => append(delta), { signal: controller.signal })A cancelled streamed completion stops at the model: Agent Serve aborts the provider request, and the app is billed only for what the model wrote. The provider reports tokens only when a stream finishes, so a cancelled one is metered from the text sent and the text written (counted the same way Agent Serve counts tokens elsewhere), priced like any other completion, and recorded as cancelled. A completion that has not streamed yet (complete), and answerChat, stop waiting when cancelled; the work already started in the workspace finishes and is billed as usual.
Semantic model
readModel() / useModel() returns { source, entities }. source is published, or draft when the person is working in their draft model. Each entity lists its attributes (with a role) and measures by name.
queryModel(params) / useModelQuery(params) runs a governed query:
const result = await queryModel({
measures: [{ entity: 'Orders', measure: 'Revenue' }],
dimensions: [{ entity: 'Orders', attribute: 'Order date', dateGrain: 'month' }],
where: { 'Orders.Account': 'Acme' },
limit: 12,
})| Param | Rules |
|---|---|
measures | 1–16 of { entity, measure }. An entity is named by id or by name. |
dimensions | Optional. Up to 4 of { entity, attribute, dateGrain? }; dateGrain is day, week, month, quarter or year. |
where | Optional. Up to 20 equality conditions keyed entity.attribute. |
sort | Optional. Up to 5 of { by, direction }, where direction is asc or desc. |
limit | Optional. 1–500 rows, default 100. |
It returns { columns, rows, truncated, warnings }. The query reaches only rows the person could read in the tables behind the model.
Code tools
runTool(name, input?) runs one of the app's own code tools on the server and resolves with { status, output, error, durationMs }. status is succeeded, failed (the tool threw or returned something its output schema refuses) or timed_out; only a refusal before the run, such as invalid input or a spent budget, rejects. The call waits up to 330 seconds, longer than the longest tool limit. listTools() returns the app's tools with their input and output schemas. An app needs no permission to run its own tools, and each run counts against its budget.
const run = await getAppClient().runTool('score_offer', offer)
if (run.status === 'succeeded') setScore(run.output)Plugins
A plugin is an app that also adds things to the workspace: Chat commands, menu items, background triggers, settings, connectors and skills. See Plugins. Three methods are for plugins:
getSettings()/useSettings()returns{ values, secretsSet }: the values an admin saved on the install's Manage page, with each field's default where nothing was saved. A secret setting is never sent to a page;secretsSetnames the secret settings that have a value, and only the plugin's code tools receive them.getLaunch()/useLaunch()returns what the page was opened for:{ kind: 'command', name, args }when a Chat command opened it,{ kind: 'action', id, target }when a menu item did, ornullwhen someone opened it from the Apps hub. The page answers this itself, so it costs nothing.callMcp({ connector, tool, arguments })calls a tool on one of the plugin's own connectors (the manifestconnectors), as the person using it. A tool that changes anything is put to the person first, like an integration action, and fails withdeclinedif they say no. It resolves with{ status, text, content, error }:textjoins the text the tool returned.
const { values } = await getAppClient().getSettings()
const launch = await getAppClient().getLaunch()
if (launch?.kind === 'command') setQuery(launch.args.file ?? '')Plugin handlers are code tools, so they do not use the client. The SDK exports their types instead: PluginHandlerInput, PluginHandlerResult and definePluginHandler, which types a handler and returns it unchanged:
import { definePluginHandler } from '@agentserve/app-sdk'
export const run = definePluginHandler((input) => {
if (input.kind !== 'action' || !input.action?.target.text) return { message: 'Nothing to brief.' }
return {
calls: [{ method: 'ai.complete', params: { model: 'claude-haiku-4-5', messages: [{ role: 'user', content: input.action.target.text }] } }],
answerWith: 0,
}
})A tool's source runs on its own, so to use the types in a handler, import them with import type or copy them in.
Under agentserve app dev, settings.get answers with each field's default, overridden by .agentserve/settings.json in the app folder; launch.get answers with --launch '<json>' when you pass it; and mcp.call fails with not_found, because connectors run only in the workspace.
Theme and size
The workspace sends the app a theme event, light or dark, when it mounts the frame and whenever the person switches theme. The first event can arrive before your code subscribes, so start from a default and follow the events:
import { useEffect, useState } from 'react'
import { getAppClient } from '@agentserve/app-sdk'
export function useTheme() {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
useEffect(() => getAppClient().onTheme(setTheme), [])
return theme
}Panels and Chat cards can fit their height to their content with resize(height), a whole number of pixels from 0 to 20,000:
getAppClient().resize(document.body.scrollHeight)Errors
A failed call rejects with an AppBridgeError. Its code is stable and safe to branch on; its message is for people.
import { AppBridgeError } from '@agentserve/app-sdk'
try {
await runWorkflow('Route ticket', { ticketId })
} catch (error) {
if (error instanceof AppBridgeError && error.code === 'budget_exhausted') {
showNotice('This app has used its budget for the month.')
} else throw error
}| Code | When |
|---|---|
invalid_request | The parameters break the protocol or a limit on this page, or the workspace refused them as invalid (for example a file over 5 MB, or a where key not written entity.attribute). |
unknown_method | The method is not part of the protocol. |
forbidden | The manifest does not name the table, workflow, Relay, knowledge base, model or integration action, or does not grant the permission (including chat: ask); the person lacks access, or their permission group withholds the model; or a file may contain secret values. |
not_found | Nothing in the workspace has that name or id: a table, workflow, Relay, file, semantic entity, code tool, knowledge base or model; or the person has no connected account for an integration action. |
conflict | The app is turned off in this workspace, several workflows or knowledge bases share the name, a workflow run could not start, or the person has several accounts for an integration and the call did not name one. |
rate_limited | More than 20 calls in one second from the app, Chat opened again within 5 seconds, a per-minute ceiling on model, media, Chat answer, knowledge or integration calls, or a workspace rate limit. |
budget_exhausted | The app has spent its monthly budget. Workflow runs, Relay tasks, code tool runs, and model, media, Chat answer, knowledge and integration calls check it. |
declined | The person declined an integration action that would have changed something, or closed the file picker. |
cancelled | The app cancelled the call with its AbortSignal. |
timeout | No answer within the client's timeout (60 seconds by default). |
internal | Something failed in the workspace, or the client was closed. |
Limits
| Limit | Value |
|---|---|
| Rows per query | 1–500, default 100 |
Rows per writeRows call | 100 |
| Columns per row written | 200 |
where conditions | 20 |
| Sort keys | 5 |
| Chat prompt | 4,000 characters |
| File read or write | 5 MB |
Files per listFiles page | 1–100, default 50 |
| Copied text | 100,000 characters |
| Download | 10 MB of content; 5 MB for a workspace file |
| Model query measures | 16 |
| Model query dimensions | 4 |
| Relay task objective | 8,000 characters |
| Model call messages | 50, 200,000 characters in all |
| Model call output | 8,192 tokens |
| Media prompt | 4,000 characters; result up to 8 MB |
| Knowledge search | 1–5 knowledge bases, 1–20 results |
| Integration action input | 64 KB |
| MCP tool call arguments | 64 KB; counted with integration actions per minute |
| Calls one plugin handler hands back | 20 |
| Model calls per minute, per install | 30 |
| Media generations per minute, per install | 10 |
| Chat answers per minute, per install | 6 |
| Knowledge searches per minute, per install | 60 |
| Integration actions per minute, per install | 20 |
| Calls per second | 20 |
| Call timeout | 60 seconds; 300 for model, media, Chat answer and integration calls |
The constants behind these limits are exported from @agentserve/app-sdk/protocol, for example APP_QUERY_MAX_LIMIT and APP_INSERT_MAX_ROWS.