Integrate onagent into your app
onagent lets an LLM drive your actual web UI. You describe what your app can do as a set of tools — named actions with typed parameters — push that definition to the platform, and embed a small browser SDK that dispatches incoming tool calls to handlers you write. This page walks through the whole path: get the CLI, create an app, define tools, and wire up the frontend.
How the pieces fit together
Four things make up an integration, and they map directly onto the four sections of this page.
- The
onagentCLI — a command-line tool you use to authenticate and manage your apps from a terminal (or a script/CI job) instead of only through the browser console. - An app in the console — a namespace (
appId) that owns one API key, one allowed origin, one set of tools, and an optional custom system prompt. - A tool definition — a YAML file (or the console's tool editor) describing each capability your page exposes: its name, a description the LLM uses to decide when to call it, and a JSON-Schema parameter shape.
- The browser SDK (
@onagent/bridge) — embedded in your page, it opens a WebSocket to the backend, registers your tool handlers, and lets you callbridge.prompt(text)to have the LLM reason about a request and dispatch tool calls back into your page in response.
At runtime: your page calls bridge.prompt("..."), the backend's inference service decides
which of your declared tools (if any) to call, the SDK receives that call over the WebSocket, runs the
matching handler you registered, and reports the outcome back to the backend.
Get the onagent CLI and log in
onagent is the command-line client for the console API. Everything it does can also be done
through the browser console — use whichever fits your workflow. It's the faster path once you're
scripting things (CI, bulk tool pushes, etc.).
Install
If you have a Go toolchain available, install straight from the module path:
$ go install github.com/tim72117/onagent/cmd/onagent@latest
This drops an onagent binary in your Go bin directory. No separate download or package registry —
the CLI lives in the same repository as the backend it talks to.
Using Claude Code?
See the Claude Code skill note further down — it can drive this entire
setup for you. It bundles a prebuilt onagent binary for Windows only; on macOS/Linux it
falls back to go install or a local build, same as above.
Log in
onagent supports two sign-in flows. Both end with a bearer token cached locally (in your
per-user config directory), so every later command runs without asking again.
Opens a browser tab for you to approve the sign-in, then a local one-shot callback server on your machine receives the token — the token itself never appears in the URL or the browser. This is the recommended default for any interactive terminal, and the only flow that shares login state with the browser console UI.
Prompts for email and password directly in the terminal, no browser involved. Use this for headless environments (SSH-only boxes, CI runners) where a browser isn't available.
# recommended for interactive use $ onagent login --web # headless / no browser available $ onagent login
Confirm it worked with:
$ onagent app list # a list of apps (even an empty one) means you're logged in. # "not logged in" means onagent login hasn't succeeded yet.
Default server
Every onagent command talks to https://onagents.dev by default (both
-api and, for login --web, -console). Both can be overridden with
the -api/-console flags. There's no environment-variable override — these
flags are the only way to redirect it, on purpose.
Full command reference
Every subcommand accepts an optional leading -api <url> flag; omitted below for brevity.
| Command | Arguments | What it does |
|---|---|---|
| login | — | Sign in with email/password typed into the terminal. |
| login --web | [-console <url>] | Sign in via a browser tab; token is exchanged through a local callback server. |
| app list | — | List your apps, each with tool count and whether a key exists. |
| app create | <appId> | Create a new app namespace. |
| app delete | <appId> | Delete an app and everything under it (tools, key, origin). Cannot be undone. |
| app origin set | <appId> <origin> | Set the exact allowed browser origin for this app's WebSocket connections. |
| app thought set | <appId> <thought> | Set (or, with an empty string, clear) the app's custom system prompt. |
| app maxpromptlength set | <appId> <value|clear> | Cap this app's own end-user prompts at value characters, or clear to fall back to the system-wide default. Can only tighten the system-wide limit, never loosen it. |
| key issue | <appId> | Mint a new API key for the app, shown once. Revokes any previous key immediately. |
| key revoke | <appId> | Revoke the app's current API key immediately. |
| tool list | <appId> | Print the app's current tool definitions as YAML. |
| tool create | <appId> <tool.yaml> | Validate and upload one tool definition from a local YAML file, adding it or replacing the existing tool of the same name — other tools on the app are untouched. |
| tool delete | <appId> <toolName> | Remove one tool from the app by name. |
appId values must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ — start with a letter or
digit, then any mix of letters, digits, -, and _.
Create an app, issue a key, set the allowed origin
These three things can each be done from the CLI or from the console's web UI — pick whichever's convenient; both act on the exact same underlying app record.
Create the app
$ onagent app create my-app Created app "my-app".
Or in the console: sign in at /app and click + New app.
Issue an API key
$ onagent key issue my-app API key for "my-app" (shown once — copy it now, it can't be retrieved again): sk_live_ab12cd34... Issuing a new key later immediately revokes this one.
The key is shown exactly once
The backend stores only a hash of the key, never the plaintext — so the moment it scrolls off your terminal (or you close the console's key modal), it's gone for good. Re-issuing a key is the only way to get a new one, and doing so immediately invalidates the previous key. Don't rotate a production app's key casually — every connection using the old key drops the instant you do.
Set the allowed origin
$ onagent app origin set my-app https://your-site.example.com Set "my-app"'s allowed origin to "https://your-site.example.com".
Use the exact origin your page is served from — scheme + host + port, no
path, no trailing slash (e.g. https://your-site.example.com, not
https://your-site.example.com/ or .../app). In the console UI, the same field is
labeled Allowed origin on the app's page.
Origin is fail-closed — this is the #1 support issue
An app with no allowed origin configured rejects every WebSocket connection outright, even with a completely correct API key. There is no "allow everything" fallback and no dev-mode bypass once a key is in play — an unset origin means the app accepts connections from nowhere. If you (or a user) report "the API key is right, but the WebSocket won't connect" or a silent connection failure with no other error, the first thing to check is whether this app's allowed origin is set, and whether it matches the page's actual origin exactly (including scheme and port).
Define tools and push them
A tool is one capability your page exposes to the LLM: a name it calls, a description that tells the
model when to use it, and a JSON-Schema-shaped set of parameters. You can define tools two ways —
hand-write a YAML file per tool and push it with onagent tool create, or use the console's
built-in tool editor. Both write to the exact same underlying definition; use whichever fits how many
tools you have and whether you want them under version control. onagent tool create adds
or replaces one tool at a time by name — it never touches the app's other tools.
tool.yaml schema
One file describes one tool — these are its fields:
| Field | Required | Notes |
|---|---|---|
| name | Yes | Identifier the LLM uses to call this tool. Must match ^[a-zA-Z_][a-zA-Z0-9_]*$ and be unique within the app. |
| description | Yes | Tells the LLM when and why to call this tool. This is the primary signal the model uses to pick between tools — be specific. |
| parameters | Yes | A JSON-Schema object: type: object, a properties map (each with a type — string, number, integer, boolean, array, or object — and optional description), and an optional required list of property names. Array-typed properties use items; object-typed properties can nest another properties/required. Any property can also take an optional enum — a list of allowed string values — to constrain the LLM to one of a fixed set instead of free text. |
| kind | No | action (default) or query — see action vs. query below. |
name: search_products description: Search the product catalog by keyword. parameters: type: object properties: query: type: string description: The search keywords. maxResults: type: integer required: - query kind: query # the LLM needs the actual search results back
A second tool, with no kind (defaults to action):
name: add_to_cart description: Add a product to the current user's shopping cart. parameters: type: object properties: productId: type: string description: The product's unique ID. quantity: type: integer description: How many units to add. Defaults to 1 if omitted. giftWrap: type: string enum: [none, standard, premium] # constrains the LLM to one of these three required: - productId # kind omitted → defaults to "action"
action vs. query — both block, they differ in what the LLM sees back
The kind field picks between two call flows. As of the current backend, both
are blocking — a tool call always holds up the in-flight request until your page responds. The
difference is what happens to that response, not whether the platform waits for it.
| kind | Blocks? | What reaches the LLM |
|---|---|---|
| action (default) | Yes | Only whether the call succeeded or failed — not any data your handler returned. Use this for side-effecting operations: submitting a form, navigating, clicking a button, adding to a cart. |
| query | Yes | The actual value your handler returns, fed back into the LLM's reasoning so it can act on real page state it has no other way to see (e.g. "what's currently selected," "what did the search return"). Use sparingly — see below. |
query tools hold a shared backend lock
A query-kind call blocks the backend's single shared orchestrator lock for as long as
your page takes to answer — a slow or unresponsive tab stalls every other user of every app on that
backend, not just your own request. Reserve kind: query for cases where the LLM
genuinely needs data only your page has; default to action (or omit kind
entirely) for anything that's just "do this and tell me if it worked."
Push tools
Save each YAML above to its own file and push them one at a time — this adds the tool, or replaces the existing tool of that name if it already exists:
$ onagent tool create my-app search_products.yaml Saved "search_products" to "my-app". $ onagent tool create my-app add_to_cart.yaml Saved "add_to_cart" to "my-app".
onagent validates the file locally before sending anything. The most common validation failures:
- Invalid tool name — doesn't match
^[a-zA-Z_][a-zA-Z0-9_]*$(no hyphens, no leading digit, no spaces). - Missing
descriptionon the tool. - Missing
parameters.type— theparametersblock (or itstypefield) was omitted entirely.
To read back what's currently saved (e.g. to diff against your local files, or seed a new file from the console editor's state):
$ onagent tool list my-app
To remove a tool from the app:
$ onagent tool delete my-app add_to_cart
Setting a custom thought (system prompt)
Beyond individual tool descriptions, an app can carry its own custom instructions for the agent that selects tools — tone, domain rules, anything beyond "call the matching tool":
$ onagent app thought set my-app "Always confirm destructive actions before calling them." Set "my-app"'s thought.
Pass an empty string to clear it and fall back to the platform default.
Capping how long a prompt can be
The platform enforces a system-wide limit on how many characters a single end-user prompt may contain (set
by whoever operates the backend, via a MAX_PROMPT_LENGTH environment variable). Each app can
additionally set its own, tighter cap — an app's own setting can only shrink the effective limit, never
raise it past the system-wide value:
$ onagent app maxpromptlength set my-app 200 Set "my-app"'s max prompt length to 200 characters.
Pass clear instead of a number to remove the app-specific cap and fall back to the system-wide
default. A prompt that exceeds the effective limit is rejected with the connection kept open — the SDK sees
it as an error carrying the code prompt_too_long, the same shape as a quota rejection.
Try it before writing any frontend code
Once an app has tools defined, the console's Playground lets you send it prompts and watch the LLM decide
whether — and how — to call them, without standing up a real page first. Select the app in the console,
then open Playground from the sidebar (a fixed bottom button on
mobile). It talks to the same backend session/tool-call protocol
@onagent/bridge uses, so a tool call that succeeds here behaves the same way once a real page
answers it — the only difference is who's on the other end producing the tool_result.
Mock templates
| Tool has… | What the Playground does |
|---|---|
A click_button or fill_form template | Renders a real, clickable mock UI (buttons or text fields, built from the tool's enum options) — the LLM calling the tool and you clicking it by hand both run the same code path and produce the same result. |
| No mock | Waits 2 seconds, then reports the call as failed with an explicit "nothing here can perform it for real" message — a nudge that this tool needs an actual page to answer it. This applies to query tools too: the Playground never fabricates an answer, since a made-up one is worse than an honest failure. |
The two built-in mock templates come from the console's tool-creation wizard, not from hand-written YAML —
building a tool as a click_button or fill_form template locks its parameter name
(label, or field/value) but leaves the enum options
freely editable, and the Playground's mock buttons/fields update immediately to match.
Embed the SDK and implement tool handlers
@onagent/bridge is the browser-side client: it opens a WebSocket to your onagent backend,
registers the tool handlers you provide, and exposes prompt(text) to kick off a request. It
buffers calls made before the connection is ready and flushes them once it's open, so you never have to
check a "ready" flag yourself.
Install
$ npm install @onagent/bridge
Construct the bridge
import { AgentBridge } from "@onagent/bridge"; const bridge = new AgentBridge({ url: "wss://onagents.dev/ws", appId: "my-app", apiKey: "YOUR_APP_API_KEY", tools: { search_products: async ({ query, maxResults }) => { const results = await searchCatalog(query, maxResults); return results; // kind: query → this value is fed back to the LLM }, add_to_cart: ({ productId, quantity }) => { cartStore.add(productId, quantity ?? 1); // kind: action (default) → only success/failure reaches the LLM, // any returned value here is ignored by the model. }, }, onAssistantMessage: (text) => showInChat(text), onError: (err) => console.error("onagent error", err), });
Constructor options:
| Option | Required | Notes |
|---|---|---|
| url | Yes | WebSocket endpoint, e.g. wss://onagents.dev/ws. Always use wss://, never ws:// — the API key rides in a URL query parameter on the handshake (browsers can't attach custom headers to a WebSocket upgrade), so an unencrypted connection puts it on the wire, and often in server access logs, in plaintext. |
| appId | Yes | The app whose tool set this session loads. |
| apiKey | No | The key from onagent key issue. When set, it always overrides appId for authorization (the backend resolves the real appId server-side from the key). Omit only when connecting to a backend explicitly configured to allow its dev/no-auth mode. |
| tools | Yes | Either a Record<string, ToolHandler> map you build yourself, or a ToolEntry[] array (see defineTool below) — the constructor normalizes either shape the same way, including a duplicate-name check. See Implement tool handlers. |
| onAssistantMessage | No | (text: string) => void — called with natural-language messages meant for display to the user (e.g. render into a chat panel). |
| onError | No | (err) => void — called on protocol/inference errors not tied to one specific call. |
| onQuotaExceeded | No | (err) => void — called instead of onError when a prompt is refused because the app owner hit their monthly quota. The connection stays open — once the plan is upgraded, further prompts on the same connection work again. Use it to show an upgrade prompt. If unset, a quota error falls through to onError like any other. |
| disconnectWhenHidden | No | Close the socket while the page is hidden (tab switched away, window minimized) and reopen it on return. Defaults to true. A WebSocket is an open request for as long as it lives, so the backend bills an instance for the whole time one is held — and a hidden page can't be showing tool results to anyone. Messages sent while hidden queue and flush on the reconnect, so callers see no difference. |
| lazyConnect | No | Wait for the first send before opening the socket, instead of connecting in the constructor. Defaults to false. Worth turning on wherever the bridge sits on a page most visitors never interact with — without it, every pageview opens a connection that exists only to be paid for. Every send already queues until the socket is ready, so the only visible cost is connection latency on the first message. |
| minBackoffMs / maxBackoffMs | No | Reconnect backoff bounds in ms. Default 500ms .. 10s. |
| beaconUrl | No | HTTP endpoint to best-effort sendBeacon any still-queued messages to when the page is hidden/unloaded, since the WebSocket closes before an in-flight send can complete. Omit to skip this fallback. |
Implement tool handlers
Each key in tools must match a tool name your app has pushed to the platform (via
tool create or the console editor). A handler receives the call's parsed arguments and
returns (or resolves to) whatever value is appropriate for that tool's kind:
- For an
actiontool, the return value is ignored by the LLM — only whether your handler threw determines success/failure. Don't rely on the model seeing it. - For a
querytool, whatever you return (sync or via a resolved Promise) is serialized and fed straight back into the LLM's reasoning. Nothing declares its shape up front, so return what the prompt actually needs to be answered. - Throwing (or an async handler's Promise rejecting) reports failure back to the backend with the error's message.
Only handlers you register can ever run
The SDK refuses to invoke anything not present in the tools map you pass — there's no
eval or dynamic dispatch path. If the backend declares a tool your page hasn't registered a handler
for, the SDK logs a console warning (backend declares tools with no registered handler:
...) rather than silently failing at call time, so mismatches surface early.
Typed handlers with defineTool
Writing tools as a plain object means every handler starts from args: any — the
type system trusts whatever shape you assert without anyone having actually checked it at runtime.
defineTool pairs a handler with a parseArgs function that both validates the raw
payload and gives Args its type, so the handler itself is fully typed:
import { AgentBridge, defineTool } from "@onagent/bridge"; interface SearchArgs { query: string; maxResults?: number; } function parseSearchArgs(raw: unknown): SearchArgs { const r = raw as Partial<SearchArgs>; if (typeof r?.query !== "string") throw new Error("query must be a string"); return { query: r.query, maxResults: r.maxResults }; } const searchProducts = defineTool( "search_products", parseSearchArgs, async ({ query, maxResults }) => { // fully typed: SearchArgs, not any return await searchCatalog(query, maxResults); } ); const bridge = new AgentBridge({ url: "wss://onagents.dev/ws", appId: "my-app", apiKey: "YOUR_APP_API_KEY", tools: [searchProducts], // ToolEntry[], not a { name: handler } map });
parseArgs is the source of truth for Args — its return type, not a bare
as Args assertion. It can be a few lines of hand-written validation like above, or wrap a
schema library's .parse method; the SDK doesn't decide that for you. A parseArgs
that throws propagates out of the resulting handler unchanged, so it's reported back as a normal
{ ok: false, error } tool result the same way any other handler error is — no separate
error-handling path to learn.
tools accepts a ToolEntry[] array directly, as shown above — the constructor
converts it to the same Record<string, ToolHandler> shape a plain object would be,
using the exported toToolRecord helper internally. Call toToolRecord yourself if
you need to merge tool arrays from multiple sources before passing them to AgentBridge. Two
entries sharing the same name — in the array or across merged arrays — throws immediately
rather than letting one silently shadow the other the way a later key in { ...a, ...b }
silently wins over an earlier one.
Send a prompt
Once constructed, call bridge.prompt(text) with a natural-language request. That's the
entire call — it takes just the text, nothing else:
searchInput.addEventListener("submit", (e) => { e.preventDefault(); bridge.prompt(userInput.value); });
The LLM reasons about the prompt, picks zero or more of your declared tools, and the SDK dispatches each
call to the matching handler automatically. Results and any natural-language reply surface through
onAssistantMessage/onError and your handlers' own side effects — there's no
separate "context" parameter to pass alongside the prompt text; any state the model needs to reason
about should come back to it through a query-kind tool call instead.
Call bridge.close() to tear the connection down permanently — no further reconnect attempts will be made after that.
Using Claude Code? There's a zero-setup skill
If you're integrating onagent from inside Claude Code, the onagent-cli-setup skill can walk
through this entire flow for you — install detection, login, app creation, key issuance, origin setup,
and pushing a tools.yaml. It's a complementary path to everything above, not a replacement:
the underlying CLI commands and tools.yaml schema are identical either way, so it's worth
reading this page regardless of whether Claude Code drives the mechanics for you.
Install the skill
Published as an npm package, bundling prebuilt onagent binaries for all five platforms
(Windows, macOS Intel/Apple Silicon, Linux x64/arm64) — no separate go install or local
build needed on any of them.
# project-level: .claude/skills/onagent-cli-setup/ $ npx -p @onagent/claude-skill claude-skill-onagent # user-level instead: ~/.claude/skills/onagent-cli-setup/ $ npx -p @onagent/claude-skill claude-skill-onagent --user
This is an explicit, user-invoked install (not a postinstall hook) — running it is the only
way it ever touches your .claude/ directory. Re-running it later overwrites the installed
copy, which is how you pick up a newer version of the skill.
Troubleshooting
Quick answers to the issues that come up most.
| Symptom | Likely cause |
|---|---|
| WebSocket won't connect, API key looks correct | A few things reject the handshake independently — an invalid/expired token, an unknown appId, quota exhaustion, or the app being switched off (Status on its console settings page) — but an unset or mismatched allowed origin is a common one worth checking first. See Origin is fail-closed above. |
onagent says "not logged in" | Run onagent login --web (or onagent login) again; the cached token may be missing or expired. |
tool create fails validation | Checked in order: name regex, missing description, missing parameters.type. The first failure found is the one you'll see. See Push tools above. |
| Console warning: "backend declares tools with no registered handler" | A tool exists on the platform (pushed via tool create or the console editor) that your tools map doesn't have a matching handler key for. Add the missing handler or remove the unused tool definition. |
| Issued a new key and everything broke | Expected — issuing a key immediately revokes the previous one. Update every deployed copy of the old key to the new one. |
| A tool call seems to hang | Likely a kind: query tool whose handler never resolves. Query tools block a shared backend lock while waiting — make sure the handler always resolves or rejects, and reserve query for cases that truly need data back. |