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

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

CommandArgumentsWhat it does
loginSign 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-appsList 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 _.

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

3

Set the allowed origin

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

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

FieldRequiredNotes
appIdNoMay 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.
thoughtNoThe app's custom system prompt for the agent that selects tools (see below). Omit to use the platform default.
toolsYesArray of tool definitions — see below. This is the only part of the file save-tools actually sends.

Each entry under tools:

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 typestring, 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.
returnsNoSame 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).
kindNoaction (default) or query — see action vs. query below.
tools.yaml
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.

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 the YAML above as tools.yaml and push it:

terminal
$ 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 description on a tool.
  • Missing parameters.type — the parameters block (or its type field) was omitted entirely.
  • Duplicate tool name within 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):

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

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

Step 4 — 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://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:

OptionRequiredNotes
urlYesWebSocket 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.
appIdYesThe app whose tool set this session loads.
apiKeyNoThe 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.
toolsYesMap of tool name → handler function. See below.
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.
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 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 action tool, 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 via get-tools/codegen tooling, but 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 — shape it to match the tool's returns schema.
  • 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:

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

Reference

Troubleshooting

Quick answers to the issues that come up most.

SymptomLikely cause
WebSocket won't connect, API key looks correctThe 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 validationCheck, 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 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.