Some logic should give the same answer every time: a pricing check, a score, a routing rule. You want it in code you can test, not in a prompt. A code tool is a function you ship with your app. Agent Serve runs it on the server, in a sandbox, whenever your app, Chat, a Relay or a workflow calls it. An agent that calls a tool cannot talk it into a different answer.
Files
A tool is two files in your repository, usually under tools/, sharing a name:
<name>.tool.yamldescribes the tool: what it does, the input it takes and the output it returns.<name>.ts,.mts,.js,.mjsor.pyis the code.
Name them in the manifest with a glob or a list of globs:
tools: ./tools/*The deal desk template ships one tool, score_offer:
name: score_offer
description: >-
Scores a sales offer from 0 to 100 against the deal desk pricing policy and says
whether to approve, review or reject it, with the reasons.
effect: read
timeoutMs: 10000
input:
type: object
required: [discountPct, marginPct, termMonths, dealSize, tier]
properties:
discountPct: { type: number, minimum: 0, maximum: 100 }
marginPct: { type: number, minimum: -100, maximum: 100 }
termMonths: { type: number, minimum: 1, maximum: 120 }
dealSize: { type: number, minimum: 0 }
tier: { type: string, enum: [strategic, enterprise, standard] }
output:
type: object
required: [score, decision, reasons]
properties:
score: { type: integer, minimum: 0, maximum: 100 }
decision: { type: string, enum: [approve, review, reject] }
reasons: { type: array, items: { type: string } }| Field | Required | Meaning |
|---|---|---|
name | Yes | Lowercase letters, digits and underscores, starting with a letter, at most 40 characters. It must match the file name. |
description | Yes | What the tool does, up to 1,024 characters. Chat and Relays read it to decide when to call the tool. |
input | Yes | A JSON Schema with type: object. Every call's input is checked against it before the tool runs. |
output | Yes | A JSON Schema with type: object. A result that does not match it fails the run. |
effect | No | read (default) or write. Use write when the tool changes something outside Agent Serve, such as sending an email or updating another system. |
timeoutMs | No | Time limit for one run, including starting the sandbox: 1,000 to 300,000. Defaults to 30,000. |
packages | No | npm or PyPI packages the tool imports, pinned the way your lockfile pins them, such as zod@3.25.76. At most 50. |
Editors can validate the file against the published schema, agentserve.tool.schema.json, with this first line:
# yaml-language-server: $schema=https://agentserve.valnoraelric.com/schemas/agentserve.tool.schema.jsonThe code
A JavaScript or TypeScript tool exports a function named run (or a default export). It gets the checked input and returns the output, or a promise of it:
export const POLICY = {
maxDiscountPct: { strategic: 25, enterprise: 15, standard: 10 },
minMarginPct: 20,
// …the rest of the policy
} as const
export function run(offer: Offer): OfferScore {
// …checks discount, margin, term and deal size, and collects the reasons
return { score, decision, reasons }
}A Python tool defines a top-level run(input), which may be async:
def run(input):
priority = "high" if input["customer_tier"] == "enterprise" else "normal"
return {"queue": "billing" if "invoice" in input["subject"].lower() else "support", "priority": priority}Keep each tool in its one file. The sandbox receives only the tool's own source, so it cannot import other files from your repository, such as your app's src/ code. It can import the packages it lists under packages. TypeScript is compiled when you upload, so a syntax error, or a module without a run export, fails the upload with the file and line.
Make tools pure where you can: no clock, no randomness, and no network unless the tool's job is to call another system. Then the same input always gives the same output, and you can test the function with any test runner without a workspace.
Deploying tools
agentserve app deploy uploads the files your tools globs name together with the build output, and a Git deploy always includes them in the bundle. On upload Agent Serve reads every <name>.tool.yaml, finds its source, checks both, and stores them with the version. Any problem refuses the whole upload and names the file.
Tool files are stored with the version but never served to the app's frame, so people using the app cannot download your tool's code. An app ships at most 50 tools, and each source file is at most 512 KB.
Tools run from the version a workspace has installed. Publishing and installing a new version, or rolling back, changes the tools for every caller at once.
Calling a tool
From your app, with the SDK. An app may always run its own tools; there is no separate permission for them.
import { getAppClient } from '@agentserve/app-sdk'
const run = await getAppClient().runTool('score_offer', offer)
if (run.status === 'succeeded') showScore(run.output)
else showError(run.error)runTool resolves with status (succeeded, failed or timed_out), output, error and durationMs. A tool that throws, runs out of time or returns something its output schema refuses is a failed run, not an exception. A refusal before the tool starts, such as invalid input or a spent budget, rejects with an AppBridgeError. listTools() returns the app's tools with their schemas.
From Chat. Chat offers each installed app's tools to its agent. When a tool's effect is write, Chat asks the person before it runs.
From Relays. A Relay can call the tools of installed apps. A write tool is handled like any other action that changes something outside the workspace: the call waits for approval under the Relay's rules.
From workflows. The App Tool block runs a tool by its reference, app-name/tool_name, such as deal-desk/score_offer. The reference is shown on the app's Manage page. The block is in preview, so it may not appear in the block list of your workspace yet.
From the API and the CLI. GET /api/v2/apps/tools lists the tools you may run, and POST /api/v2/apps/installs/{installId}/tools/{toolName}/run runs one. Both need your own login or personal API key; a workspace API key is refused, because a tool always runs for a person whose access can be checked.
Whoever calls a tool needs access to the Apps module in the workspace. The person who asked is the one recorded for the run.
Secrets
A tool that calls another system usually needs a key. Store it as a per-install secret with agentserve app secrets set STRIPE_KEY or on the app's Manage page. Each secret reaches the tool as an environment variable of the same name:
const key = process.env.STRIPE_KEYimport os
key = os.environ["STRIPE_KEY"]Secrets reach only the tool's own process. They never reach the browser bundle or the app's frame. Any secret value that appears in a tool's error is replaced with [secret], and a run whose output contains a secret value fails with its output withheld.
Budgets, metering and logs
- Budget. Tool runs count against the app's monthly budget, like the workflow runs and Relay tasks it starts. Once the budget is spent, runs are refused with
budget_exhausteduntil the next month or until an admin raises it. - Metering. Each run's sandbox time is recorded in the usage ledger as
app-tooland paid by the workspace of the person who called it, or by the workflow run that called it. The record names the app, the install and the version. - Logs. Every run, whether it succeeded, failed or timed out, is in the workspace audit trail with the tool, the surface that called it, the status and the duration. Inputs, outputs and secrets are never recorded.
The app's Manage page has a Tools section listing what the installed version ships, the last runs of each tool, and a Test run form for builders. A test run is a real run: it is metered, counts against the budget and, for a write tool, makes real changes.
Packages and sandboxes
Tools run in a remote sandbox, built from the tool's language and its packages. Tools that list the same packages share a prepared sandbox image, so they start faster; tools with no packages use the standard Function image. Packages are installed from npm for JavaScript and TypeScript, and from PyPI for Python.
Limits
| Limit | |
|---|---|
| Tools per app | 50 |
| Source file | 512 KB |
input or output schema | 32 KB each |
| Input of one call | 256 KB |
| Output of one run | 1 MB |
| Time per run | 1 to 300 seconds, 30 by default |
agentserve app dev serves your pages from your machine, but tools only run on Agent Serve. In dev, runTool and listTools use the version of your app installed in the workspace, not your local tools/ files. Deploy, publish and install once to try them; see agentserve app dev.