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/backend/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 bundles a prebuilt
onagent binary and can drive this entire setup for you, no local install required.
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 list-apps # 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://onagent.shuttle.tools by default (both
-api and, for login --web, -console). Point at a local dev
server with e.g. -api http://localhost:8080 -console http://localhost:8080. There's no
environment-variable override — -api/-console 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. |
| list-apps | — | List your apps, each with tool count and whether a key exists. |
| create-app | <appId> | Create a new app namespace. |
| issue-key | <appId> | Mint a new API key for the app, shown once. Revokes any previous key immediately. |
| set-origin | <appId> <origin> | Set the exact allowed browser origin for this app's WebSocket connections. |
| set-thought | <appId> <thought> | Set (or, with an empty string, clear) the app's custom system prompt. |
| save-tools | <appId> <tools.yaml> | Validate and upload the tools array from a local YAML file. |
| get-tools | <appId> | Print the app's current tool definitions as YAML, in the same shape save-tools reads. |
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 create-app my-app Created app "my-app".
Or in the console: sign in at /app and click + New app.
Issue an API key
$ onagent issue-key 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 set-origin 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 tools.yaml and push it with onagent save-tools, 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.
tools.yaml schema
Top-level fields:
| Field | Required | Notes |
|---|---|---|
| appId | No | May be present but is ignored by onagent save-tools — the appId given as a command-line argument always wins. This lets one tools.yaml be reused verbatim across multiple apps. |
| thought | No | The app's custom system prompt for the agent that selects tools (see below). Omit to use the platform default. |
| tools | Yes | Array of tool definitions — see below. This is the only part of the file save-tools actually sends. |
Each entry under tools:
| 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. |
| returns | No | Same shape as parameters, documenting what the frontend sends back. Not sent to the LLM as part of the tool schema — used for TypeScript codegen, and for query-kind tools, this is also the shape the LLM receives back as data (see below). |
| kind | No | action (default) or query — see action vs. query below. |
appId: my-app # optional — ignored by save-tools; the CLI arg wins thought: "" # optional — custom system prompt, or omit for the default tools: - 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 returns: type: array items: type: object properties: id: { type: string } name: { type: string } kind: query # the LLM needs the actual search results back - 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. 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 the YAML above as tools.yaml and push it:
$ onagent save-tools my-app tools.yaml Saved 2 tools 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 a tool. - Missing
parameters.type— theparametersblock (or itstypefield) was omitted entirely. - Duplicate tool
namewithin the same app.
To read back what's currently saved (e.g. to diff against your local file, or seed a new file from the console editor's state):
$ onagent get-tools my-app > tools.yaml
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 set-thought 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; this can also be embedded as the top-level thought field in tools.yaml.
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://onagent.shuttle.tools/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://onagent.shuttle.tools/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 issue-key. 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 | Map of tool name → handler function. See below. |
| 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. |
| 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
save-tools 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. Still return something meaningful if you want it visible viaget-tools/codegen tooling, but 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 — shape it to match the tool'sreturnsschema. - 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.
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
(.claude/skills/onagent-cli-setup/) can walk through this entire flow for you — install
detection, login, app creation, key issuance, origin setup, and pushing a tools.yaml — using
a bundled prebuilt onagent binary, so there's nothing to install manually first. 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.
Troubleshooting
Quick answers to the issues that come up most.
| Symptom | Likely cause |
|---|---|
| WebSocket won't connect, API key looks correct | The app's allowed origin is unset or doesn't exactly match the page's origin. See Origin is fail-closed above — this is the most common cause by far. |
onagent says "not logged in" | Run onagent login --web (or onagent login) again; the cached token may be missing or expired. |
save-tools fails validation | Check, in order: tool name regex, missing description, missing parameters.type, duplicate tool names. See Push tools above. |
| Console warning: "backend declares tools with no registered handler" | A tool exists on the platform (pushed via save-tools 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. |