onagent/docs
Integration guide

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.

Overview

How the pieces fit together

Four things make up an integration, and they map directly onto the four sections of this page.

  1. The onagent CLI — 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.
  2. 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.
  3. 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.
  4. The browser SDK (@onagent/bridge) — embedded in your page, it opens a WebSocket to the backend, registers your tool handlers, and lets you call bridge.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.

Step 1 — CLI

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:

terminal
$ 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.

onagent login --web [-api <url>] [-console <url>]

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.

onagent login [-api <url>]

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.

terminal
# recommended for interactive use
$ onagent login --web

# headless / no browser available
$ onagent login

Confirm it worked with:

terminal
$ 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.

CommandArgumentsWhat 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 _.

Step 2 — Console

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.

1

Create the app

terminal
$ onagent app create my-app
Created app "my-app".

Or in the console: sign in at /app and click + New app.

2

Issue an API key

terminal
$ 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.

3

Set the allowed origin

terminal
$ 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).

Step 3 — Tools

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:

FieldRequiredNotes
nameYesIdentifier the LLM uses to call this tool. Must match ^[a-zA-Z_][a-zA-Z0-9_]*$ and be unique within the app.
descriptionYesTells the LLM when and why to call this tool. This is the primary signal the model uses to pick between tools — be specific.
parametersYesA 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.
kindNoaction (default) or query — see action vs. query below.
search_products.yaml
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):

add_to_cart.yaml
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.

kindBlocks?What reaches the LLM
action (default)YesOnly 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.
queryYesThe 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:

terminal
$ 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 description on the tool.
  • Missing parameters.type — the parameters block (or its type field) 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):

terminal
$ onagent tool list my-app

To remove a tool from the app:

terminal
$ 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":

terminal
$ 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:

terminal
$ 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.

Step 4 — Playground

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 templateRenders 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 mockWaits 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.

Step 5 — Frontend SDK

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

terminal
$ npm install @onagent/bridge

Construct the bridge

app.ts
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:

OptionRequiredNotes
urlYesWebSocket 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.
appIdYesThe app whose tool set this session loads.
apiKeyNoThe 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.
toolsYesEither 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.
onAssistantMessageNo(text: string) => void — called with natural-language messages meant for display to the user (e.g. render into a chat panel).
onErrorNo(err) => void — called on protocol/inference errors not tied to one specific call.
onQuotaExceededNo(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.
disconnectWhenHiddenNoClose 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.
lazyConnectNoWait 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 / maxBackoffMsNoReconnect backoff bounds in ms. Default 500ms .. 10s.
beaconUrlNoHTTP 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 action tool, 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 query tool, 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:

app.ts
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:

app.ts
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.

Alternative path

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.

terminal
# 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.

Reference

Troubleshooting

Quick answers to the issues that come up most.

SymptomLikely cause
WebSocket won't connect, API key looks correctA 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 validationChecked 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 brokeExpected — 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 hangLikely 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.