# Client-side tools and the embeddable widget

> Tracon documentation. Tracon is a .NET package family that adds a control
> plane on Microsoft Agent Framework, published as NuGet packages that run
> inside your own ASP.NET Core application.
>
> Page: https://tracon.dev/guides/client-side-tools/
> Summary: Register a tool whose body runs in the browser, answer its call over HTTP, and drop the pre-built chat widget into your own page.

A normal tool's body runs in your server process. Some data only exists somewhere
else — the current page's DOM, a shopping cart in `localStorage`, a file the user
just selected. A client-side tool lets the model call something that runs on the
**caller** instead.

## The mechanic

```csharp
builder.AddTracon()
       .AddClientTool(
           "read_shopping_cart",
           "Reads the items currently in the customer's shopping cart in the browser.",
           JsonSerializer.Deserialize<JsonElement>("""{"type":"object","properties":{}}"""));
```

The declaration — name, description, JSON schema — lives in code, exactly like
[every other tool](/concepts/tools/): the console can show it, but nothing
short of a code change can add one. What is different is the body: there is none.
When the model calls `read_shopping_cart`, the server never runs it. The run response
carries a pending `FunctionCallContent` instead, and the caller is expected to answer
it.

```mermaid
sequenceDiagram
    accTitle: Client-side tool call round trip
    accDescr: The caller sends a message, the server returns a pending tool call it never ran, the caller resolves it locally and sends the result back, and the server returns the final text.
    participant Caller as Caller (browser)
    participant Server as Tracon

    Caller->>Server: POST /run {"message": "what's in my cart?"}
    Server->>Server: model calls read_shopping_cart
    Server-->>Caller: functionCall (no body ran)
    Caller->>Caller: reads localStorage itself
    Caller->>Server: POST /run {"sessionId", "toolResults": [{"callId", "result"}]}
    Server-->>Caller: final text
```

Answer it with `toolResults` on the next request, matched by `callId`:

```bash
curl -X POST https://your-host/tracon/api/agents/support/run \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "s-123",
    "toolResults": [{ "callId": "call_abc", "result": "2x Wireless Mouse" }]
  }'
```

A few rules follow directly from this being an HTTP round trip, not a callback:

- **`sessionId` is required.** The pending call lives in session history; a
  sessionless run has nowhere to look it up.
- **A queued run (`Prefer: respond-async`) does not accept `toolResults`.** Nothing
  is connected on the other end of a background job. The same holds for the answer to
  an MCP server's [request for input](/concepts/tools/#a-server-asks-the-user-for-input),
  which also travels as the input of the next request.
- **An unknown or already-answered `callId` is rejected** (`400`/`409`) before the
  response starts streaming, not silently ignored.
- **A failure is still an answer.** If the browser could not fulfil the call — a
  permission was denied, an element was not found — send `errorMessage` instead of
  `result`. The model sees the failure as ordinary tool output and can recover
  (explain, retry, ask a follow-up); it does not become an HTTP error.

```json
{ "callId": "call_abc", "errorMessage": "The user denied camera access." }
```

A client-side tool cannot also `RequiresApproval`. Approval defers a server-side
call; a client-side tool has no server-side call to defer. `AddClientTool` never
exposes the flag, and a direct registration that tries to combine them fails at
startup with a clear message.

Every result passes through the same [content guard](/concepts/governance/)
pipeline as any other message before it reaches the model — nothing new to configure,
but nothing exempted either.

## A front end that speaks AG-UI

An [AG-UI front end](/guides/ag-ui/) such as CopilotKit declares its tools on every request. That declaration does not
create a tool: only a client-side tool registered with `AddClientTool` and named in the agent's definition reaches
the model, with the schema from the code. The front end's declaration says which of them it can run. A client-side
tool the front end does not declare is refused with `400`; a declared tool the agent does not carry is ignored. The
result goes back as a `tool` message instead of `toolResults`.

## Client-side tools cannot be replayed

An agent carrying a client-side tool cannot be [replayed](/concepts/runs/#replay-and-comparison) in any
tool mode. The tool's body runs in the caller's browser, not on the server, so no
call to it is ever recorded — there is no result to play back, and no client waiting
to answer it live. `POST /api/runs/{runId}/replay` rejects the request with `409
Conflict` before the run starts, naming the tool in the response `detail`. This
applies even if that particular run never actually called the tool: the check looks
at what the agent's definition carries, not at what one run happened to use.

## CORS

A page calling `/run` from its own origin needs a CORS response, and Tracon sends
none by default:

```csharp
app.MapTracon("/tracon", options =>
{
    options.AllowedOrigins.Add("https://shop.example.com");
});
```

There is no `AllowAnyOrigin`. An endpoint that accepts a bearer token or an API key
must not make a wildcard origin the easy path — add the exact origins that need
access. With the list empty, no `Access-Control-Allow-Origin` header is ever sent and
a cross-origin browser request is blocked by the browser itself; a same-origin caller
(curl, a server-to-server call, your own console) is unaffected either way.

An allowed origin can read a response from **any** endpoint under the prefix, not
only `/run` — CORS applies to the whole group. This does not by itself grant access:
reading a response still needs a valid credential, and CORS only controls whether the
calling page's own script is allowed to read what that credential already permits.
Add an origin only where you also control which credential reaches it — in practice,
an origin you allow should only ever receive a `RunsWrite`-scoped key, never the
management token.

## The embeddable widget

`Tracon.UI` also builds a small, framework-free chat widget — a separate bundle
from the console, with its own 30 KB gzip budget. Drop it into any page with one
script tag:

```html
<script src="https://your-tracon-host/tracon/embed/embed.js"
  data-server="https://your-tracon-host"
  data-prefix="/tracon"
  data-agent="support"
  data-api-key="sk_..."></script>
<script>
  // Runs after the widget script, in document order.
  window.TraconEmbed.registerTool('read_shopping_cart', () => {
    const cart = JSON.parse(localStorage.getItem('cart') ?? '[]');
    return cart.map((item) => `${item.qty}x ${item.name}`).join(', ');
  });
</script>
```

`registerTool` supplies the **implementation** for a tool the server already
declared by name — the widget never sees a schema, and it cannot invent a new tool.
The handler may be synchronous or return a `Promise<string>`; if the model calls a
tool with no registered handler, the widget answers with an `errorMessage` on your
behalf instead of leaving the call hanging.

The widget carries its own small dictionary (English and Turkish), independent from
the console's — pulling in the console's translations would blow its budget for a
handful of strings.

### The identity it needs

`data-api-key` is a [tenant API key](/getting-started/security/) scoped to
`RunsWrite`, not the management bearer token. The token that unlocks the console must
never reach a browser outside your own network; a scoped, revocable API key is the
credential meant for exactly this.

## Read next

- [Add a tool](/getting-started/tools/) — the server-side default
- [Tools, skills, and MCP](/concepts/tools/) — where each capability's code
  actually runs
- [Security](/getting-started/security/) — API keys, scopes, and the
  three-layer access model
