App SDK

@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:

package.json
"dependencies": {
  "@agentserve/app-sdk": "file:./vendor/app-sdk"
}

Run bun install (or npm install) once after init. The SDK has three entry points:

ImportWhat it holds
@agentserve/app-sdkAppClient, getAppClient, AppBridgeError and the result types
@agentserve/app-sdk/reactReact hooks and one-call functions (React 19)
@agentserve/app-sdk/protocolThe 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

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

FunctionResolves toNeeds
writeRows(table, rows){ rows }: the rows addedThe table in permissions.tables
runWorkflow(workflow, input?)The run's result, after it finishesThe workflow in permissions.workflows
queueWorkflow(workflow, input?){ executionId, status: 'queued' }, once queuedThe 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 contentfiles: read or write
writeFile({ name, content, encoding?, contentType? }){ id, name, contentType, size }files: write
readModel()The semantic modelmodel: read
queryModel(params)Rows of a model querymodel: 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 onDeltaThe 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 pickerfiles: read or write
copyText(text){ copied: true }, once the text is on the person's clipboardNothing
download({ filename, content, encoding?, mimeType? }) or download({ fileId, filename? }){ started: true }, once the browser saves the fileNothing; 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.

MethodBridge 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:

FieldType
userId, name, emailThe person using the app. name and email may be null.
roleTheir workspace role: read, write or admin.
workspaceIdThe workspace the app is installed in.
app{ id, installId, versionId, version, slug } of the running install.
permissionsThe installed version's permissions, with defaults filled in. Slot names appear in the list of their kind.
slotsEach 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:

ParamRules
tableA table name listed in permissions.tables.
whereOptional. 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.
sortOptional. Up to 5 of { column, direction }, where direction is asc or desc.
columnsOptional. Up to 200 column names to return.
limitOptional. 1–500 rows, default 100.
cursorOptional. 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,
})
ParamRules
modelA model id listed in permissions.ai.models.
messages1–50 of { role, content }, where role is system, user or assistant; 200,000 characters in all.
maxTokensOptional. 1–8,192.
temperatureOptional. 0–2.
responseFormatOptional. 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.

ParamRules
folderOptional. A folder path such as Contracts/2026; the workspace root when left out.
searchOptional. Words in the file name, 1–200 characters.
typesOptional. Up to 20 extensions (.pdf) or media types (application/pdf, image/*).
recursiveOptional. Include files in subfolders. Defaults to true with a search, false without.
limitOptional. 1–100 files, default 50.
cursorOptional. 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. encoding is utf8 (the default) or base64. The file is at most 10 MB.
  • { fileId, filename? } saves a workspace file the person can read; it needs files: read or write and is at most 5 MB, the readFile limit.

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,
})
ParamRules
measures1–16 of { entity, measure }. An entity is named by id or by name.
dimensionsOptional. Up to 4 of { entity, attribute, dateGrain? }; dateGrain is day, week, month, quarter or year.
whereOptional. Up to 20 equality conditions keyed entity.attribute.
sortOptional. Up to 5 of { by, direction }, where direction is asc or desc.
limitOptional. 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; secretsSet names 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, or null when 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 manifest connectors), as the person using it. A tool that changes anything is put to the person first, like an integration action, and fails with declined if they say no. It resolves with { status, text, content, error }: text joins 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:

tools/file_brief.ts
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
}
CodeWhen
invalid_requestThe 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_methodThe method is not part of the protocol.
forbiddenThe 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_foundNothing 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.
conflictThe 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_limitedMore 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_exhaustedThe app has spent its monthly budget. Workflow runs, Relay tasks, code tool runs, and model, media, Chat answer, knowledge and integration calls check it.
declinedThe person declined an integration action that would have changed something, or closed the file picker.
cancelledThe app cancelled the call with its AbortSignal.
timeoutNo answer within the client's timeout (60 seconds by default).
internalSomething failed in the workspace, or the client was closed.

Limits

LimitValue
Rows per query1–500, default 100
Rows per writeRows call100
Columns per row written200
where conditions20
Sort keys5
Chat prompt4,000 characters
File read or write5 MB
Files per listFiles page1–100, default 50
Copied text100,000 characters
Download10 MB of content; 5 MB for a workspace file
Model query measures16
Model query dimensions4
Relay task objective8,000 characters
Model call messages50, 200,000 characters in all
Model call output8,192 tokens
Media prompt4,000 characters; result up to 8 MB
Knowledge search1–5 knowledge bases, 1–20 results
Integration action input64 KB
MCP tool call arguments64 KB; counted with integration actions per minute
Calls one plugin handler hands back20
Model calls per minute, per install30
Media generations per minute, per install10
Chat answers per minute, per install6
Knowledge searches per minute, per install60
Integration actions per minute, per install20
Calls per second20
Call timeout60 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.