---
title: "HTTP API: Call Actions from Anything"
description: "Call an agent-native app's actions over plain HTTP with a long-lived bearer token — from a support-desk bot, a CI job, a cron script, or any backend service, no browser session required."
search: "HTTP API bearer token service token connect CLI curl action endpoint server-to-server webhook CI backend integration Authorization header revoke rotate"
---

# HTTP API

**This page: call an app's actions from an external system over HTTP.** Use it when a support-desk bot, a CI job, a cron script, a Lambda, or any backend service needs to invoke your app without a browser and without an MCP client.

| If you want to…                                      | Read                                     |
| ---------------------------------------------------- | ---------------------------------------- |
| Call one action from a script or backend service     | **This page** — HTTP API                 |
| Let an AI host discover and call every action        | [MCP Server](/docs/mcp-protocol)         |
| Connect Claude, ChatGPT, Codex, Cursor to your app   | [External Agents](/docs/external-agents) |
| Have another agent-native app delegate work to yours | [A2A Protocol](/docs/a2a-protocol)       |
| Define the operation in the first place              | [Actions](/docs/actions-overview)        |

Every `defineAction` in `actions/` is already an HTTP endpoint. The frontend calls it through `useActionQuery` / `useActionMutation` with a session cookie, but the same route accepts an `Authorization: Bearer` token, so anything that can make an HTTPS request can call it. There is no separate "public API" to build and no REST wrapper to maintain — the action you wrote for the agent and the UI is the API.

## When to use this surface {#when}

Pick HTTP when the caller is code and already knows exactly which operation it wants:

| Caller                                                 | Use                                    |
| ------------------------------------------------------ | -------------------------------------- |
| A script, backend service, CI job, or webhook consumer | **HTTP** — this page                   |
| An LLM host that should discover tools and choose      | [MCP](/docs/mcp-protocol)              |
| Another agent-native app delegating a fuzzy task       | [A2A](/docs/a2a-protocol)              |
| A browser page in your own app                         | `useActionQuery` / `useActionMutation` |

HTTP is the cheapest of the three: one request, one JSON body, one JSON response, no handshake, no session, no model in the loop. MCP and A2A both sit on top of the same actions — reach for them when the caller needs a tool catalog or the whole agent loop, not a single known operation.

## Mint a token {#mint-token}

Tokens come from the `connect` CLI. There are two kinds, and the choice matters.

**Personal token** — bound to the person who runs the command. Use it for your own scripts and local development.

```bash
npx @agent-native/core@latest connect https://clips.example.com
```

This opens a browser sign-in, then writes the token into your local MCP client configs. Rows the token creates are owned by you.

**Org service token** — bound to a synthetic, organization-owned identity rather than a person. Use it for anything that must keep running after people change teams: CI, a support-desk bot, a scheduled integration.

```bash
npx @agent-native/core@latest connect https://clips.example.com \
  --service-token support-desk --ttl-days 90
```

The command still authenticates you in the browser first — minting requires org **owner or admin** — but the resulting token's subject is `svc-support-desk@service.<orgId>`. It survives you leaving the org or revoking your own personal tokens, and the rows it creates are org-scoped, so every org member can see them. The token value is printed exactly once and is never written to a local config file; copy it straight into your secret store.

|                              | Personal token              | Org service token                |
| ---------------------------- | --------------------------- | -------------------------------- |
| Identity                     | The person who minted it    | `svc-<name>@service.<orgId>`     |
| Survives that person leaving | No                          | Yes                              |
| Rows it creates              | Owned by that person        | Org-scoped                       |
| Who can mint                 | Any signed-in user          | Org owner/admin only             |
| Best for                     | Your own scripts, local dev | CI, bots, scheduled integrations |

Personal tokens default to 365 days in the CLI device flow; to choose a lifetime from 1 to 365 days, use the app's Connect page at `https://<app>/mcp/connect`. Org service tokens also default to 365 days and accept `--ttl-days` values between 1 and 3650. If a machine cannot open a browser, the app's Connect page is the fallback: sign in there, copy the token, and pass it with `connect <url> --token <token>` or paste it directly into your secret store.

For a self-hosted, single-tenant app where per-person identity does not matter, the static `ACCESS_TOKEN` / `ACCESS_TOKENS` environment variables are the simplest option — see [Authentication — Static MCP Bearer Tokens](/docs/authentication#access-tokens).

## Call an action {#call}

The route is `POST /_agent-native/actions/<action-id>`. The action id defaults to the action's filename, so `actions/import-loom-recording.ts` mounts at `/_agent-native/actions/import-loom-recording`.

The request body is the raw JSON of the action's zod schema — no envelope, no `{ args: ... }` wrapper. The response is the action's return value serialized bare at the top level, not wrapped in `{ result: ... }`.

```bash
curl -sS -X POST \
  https://clips.example.com/_agent-native/actions/import-loom-recording \
  -H "Authorization: Bearer $AGENT_NATIVE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://www.loom.com/share/EXAMPLE_SHARE_ID"}'
```

```json
{
  "recordingId": "rec_8f2a91",
  "title": "Checkout fails on Safari 17",
  "status": "imported"
}
```

<Endpoint id="doc-block-http-api-action" title="Call an action over HTTP" method="POST" path="/_agent-native/actions/import-loom-recording" summary={"Invoke any action by id with a bearer token — no browser session required."} auth={"Authorization: Bearer <connect-minted personal or org service token>, or a session cookie for same-origin browser calls"} params={[
  {
    "name": "Authorization",
    "in": "header",
    "type": "string",
    "required": true,
    "description": "Bearer <token> from `connect` or `connect --service-token`. Omit only for same-origin browser calls that carry the session cookie."
  },
  {
    "name": "Content-Type",
    "in": "header",
    "type": "string",
    "required": true,
    "description": "application/json for POST actions."
  }
]} request={{
  "contentType": "application/json",
  "example": "{\n  \"url\": \"https://www.loom.com/share/EXAMPLE_SHARE_ID\"\n}"
}} responses={[
  {
    "status": "200",
    "description": "The action's return value, serialized bare at the top level.",
    "example": "{\n  \"recordingId\": \"rec_8f2a91\",\n  \"title\": \"Checkout fails on Safari 17\",\n  \"status\": \"imported\"\n}"
  },
  {
    "status": "400",
    "description": "Input failed the action's zod schema before run() fired.",
    "example": "{ \"error\": \"Invalid action parameters: url: Required\" }"
  },
  {
    "status": "401",
    "description": "Missing, expired, revoked, or wrong-audience token."
  },
  {
    "status": "405",
    "description": "Wrong HTTP method for this action.",
    "example": "{ \"error\": \"Method not allowed. Use POST.\" }"
  },
  {
    "status": "500",
    "description": "The action threw. The real message stays server-side.",
    "example": "{ \"error\": \"Internal server error\" }"
  }
]}>

The body is validated against the action's zod schema before `run` executes. The token is verified the same way the app's MCP endpoint verifies it: same signature check, same audience binding to `{appUrl}/mcp`, same revocation gate. The caller's organization comes from the token's signed `org_id` claim — never from an ambient browser cookie that happens to ride along on the request.

</Endpoint>

### GET actions and query params {#get-actions}

An action that declares an `http` override is reachable with the method it names, and `GET` args arrive as query params instead of a JSON body:

```ts filename="actions/get-lead.ts"
export default defineAction({
  description: "Get details for a lead.",
  schema: z.object({ leadId: z.string() }),
  http: { method: "GET" },
  run: async ({ leadId }) => {
    /* ... */
  },
});
```

```bash
curl -sS \
  "https://crm.example.com/_agent-native/actions/get-lead?leadId=lead_123" \
  -H "Authorization: Bearer $AGENT_NATIVE_TOKEN"
```

`http: { path: "..." }` changes the mounted URL for direct HTTP callers only, and `http: false` removes the endpoint entirely. See [Actions — HTTP config](/docs/actions-defining#http) for the full option set.

### Errors {#errors}

Every failure returns `{ "error": string }` with an appropriate status:

| Status | Meaning                                                                                                                                             |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Input failed the action's zod schema. The message names the offending field.                                                                        |
| `401`  | Missing, expired, revoked, or wrong-audience bearer token.                                                                                          |
| `403`  | Authenticated, but not allowed to touch this data — see [When it 403s](#troubleshooting).                                                           |
| `405`  | Wrong method. The message tells you which one the action declares.                                                                                  |
| `409`  | Only for browser clients running a stale build; server-to-server callers never see it.                                                              |
| `500`  | The action threw. The response message is always `Internal server error` — the real detail is logged server-side with a capture id, never returned. |

Treat a `500` as "check the app's logs", not "read the error text". Actions that want to return a specific, user-facing failure should throw with an explicit `statusCode` below 500; those messages do come back verbatim.

### CSRF and CORS {#csrf-cors}

A cookieless server-to-server call skips CSRF entirely — there is no session cookie to confuse, so there is nothing to protect against. You do not need to fetch a CSRF token, and you should not send cookies.

Cross-origin calls _from a browser_ are different: they need an allowlisted origin. If you are calling from browser JavaScript on another domain, configure the app's CORS allowlist first. Server-side callers are unaffected.

## Worked example: a support-desk bot {#worked-example}

A support-desk bot watches inbound tickets. When a customer attaches a Loom share link to a bug report, the bot imports it into Clips so the support team can comment on the recording, clip the relevant 20 seconds, and attach it to the engineering issue.

**1. Mint a token the bot owns.** Run this once, as an org owner or admin:

```bash
npx @agent-native/core@latest connect https://clips.example.com \
  --service-token support-desk --ttl-days 365
```

Copy the printed value into the bot's secret store as `AGENT_NATIVE_TOKEN`. It is shown once and never stored by the app.

**2. Call the action when a ticket arrives.**

```ts
const res = await fetch(
  "https://clips.example.com/_agent-native/actions/import-loom-recording",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AGENT_NATIVE_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ url: shareUrl }),
  },
);

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`Clips import failed (${res.status}): ${error}`);
}

const { recordingId } = await res.json();
```

**3. Link back to the recording.** Post `https://clips.example.com/r/${recordingId}` into the ticket thread. Because the bot used an org service token, the recording is org-scoped: every teammate who opens that link sees it, and it does not disappear when the person who set up the bot changes teams.

The action ran exactly as it would have from chat or the UI — same validation, same access checks, same app state write that focuses the new recording for anyone who has the app open.

## Lifetime, rotation, and revocation {#lifecycle}

Tokens are JWTs signed by the app and bound to that app's audience. A token minted for one app cannot call another.

- **Lifetime** — 365 days by default, `--ttl-days` between 1 and 3650. Shorter is better for anything that runs unattended.
- **Rotation** — mint the new token, deploy it to the caller, confirm traffic is flowing, then revoke the old one. There is no in-place rotation; the two tokens are independent and can overlap.
- **Revocation** — every token carries a `jti`, and revocation is a `jti` check on every request, so a revoked token stops working immediately. Use the `list-org-service-tokens` action to see names, who minted them, and last-used timestamps, then `revoke-org-service-token` with the id. Both are owner/admin-gated for writes; any org member can list.
- **Recovery** — the token value is never stored, only its `jti`. If you lose it, mint a new one and revoke the old.

Because `list-org-service-tokens` reports `lastUsedAt`, it doubles as a cleanup tool: anything that has not been used in months is a candidate for revocation.

<Callout id="doc-block-http-api-security" tone="warning">

**Treat the token exactly like a password.** It authenticates as a real identity in your organization, and it is long-lived by default.

- Put it in a secret manager or CI secret, never in source, a Dockerfile, a log line, or a client-side bundle.
- Give each caller its own named service token (`--service-token support-desk`, `--service-token ci`) so you can revoke one without breaking the others, and so `lastUsedAt` tells you who is actually calling.
- Prefer the shortest `--ttl-days` the caller can live with.
- A service token acts as an org **`member`, and only ever `member`.** The synthetic `svc-*@service.<orgId>` identity is never inserted into `org_members`; it resolves an implicit member role for the org it was minted against, and nothing higher. So it can read and write that org's data like any member, but it cannot mint further tokens, revoke existing ones, or manage org members and settings — every one of those is admin-gated. It can still do everything a normal member can do with the org's data, so scope it deliberately.
- Actions you never want reachable this way should set `http: false` (agent and CLI only) or `toolCallable: false`. See [Actions — Exposure flags](/docs/actions-access-control#exposure-flags).

</Callout>

## Service principals {#service-principals}

Each org service token is a **service principal**, and a governance record says who answers for it and what it may do. Owners and admins set it when minting (`create-org-service-token` takes optional `ownerEmail`, `team`, `riskTier`, `purpose`, and `allowedActions`) and change it later with `set-service-principal-policy`. `list-org-service-tokens` returns each principal's record in `principals`.

| Field             | Meaning                                                                                                                                         |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `ownerEmail`      | The accountable human. Defaults to the admin who minted the token.                                                                              |
| `team`, `purpose` | Who to page and what the principal is for.                                                                                                      |
| `riskTier`        | `low`, `medium`, or `high`.                                                                                                                     |
| `lifecycle`       | `active`, `suspended`, or `retired`.                                                                                                            |
| `allowedActions`  | `null` is unrestricted. A list is a deny-by-default grant of exact action names or a trailing `*` prefix pattern; an empty list grants nothing. |

- **Grant** — `tools/list` shows only the granted actions, and any call outside the grant is refused, whether it arrives over MCP, an HTTP action route, or an agent run the principal started with `ask-agent` or `ask_app`. Agent built-ins such as `tool-search` and `view-screen` are covered too, so name them in the grant if the principal needs them. The grant narrows what the `member` role already allows; it never adds access.
- **Suspend and resume** — `set-service-principal-lifecycle` with `suspended` refuses the principal on every request, refuses new agent runs for it, and aborts its in-flight runs. `active` resumes it. The MCP connect page (`/mcp/connect`) has the same suspend and resume controls. `retired` also revokes its tokens and cannot be undone.
- **Ungoverned** — a service token minted before governance has no record. It stays active and unrestricted until an admin sets a policy.
- **Fails closed** — if the policy cannot be read, the request is refused with a `503`, never admitted.

These actions are owner/admin-only and are not callable by the agent, so neither an agent nor a service token can loosen its own grant.

## When it 403s {#troubleshooting}

A `401` means the token was not accepted at all. A `403` means the token _was_ accepted and the request then failed an access check — a different problem with a different fix.

| Symptom                                                          | Likely cause                                                                                                                                                                      |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401` on every call, immediately after minting                   | Token minted against a different app URL. It is audience-bound to `{appUrl}/mcp`; mint it against the app you are calling.                                                        |
| `401` that started working and then stopped                      | Expired (check `--ttl-days`) or revoked. Check `list-org-service-tokens` with `includeRevoked: true`.                                                                             |
| `403` on a data action                                           | The identity is not scoped to the org that owns the row. Confirm the token was minted with an active org, and that the row is org-scoped rather than owned by a person.           |
| `403` on a data action from a service token only                 | The app authorizes with its own `org_members` role lookup and has not adopted `implicitServiceOrgRole` from `@agent-native/core/org`, so the synthetic identity resolves no role. |
| `403` on `create-org-service-token` / `revoke-org-service-token` | The caller is an org `member`, or is a service token. Minting and revoking require a human owner/admin.                                                                           |
| `403` from browser JavaScript, but curl works                    | Cross-origin browser call from an origin that is not allowlisted. See [CSRF and CORS](#csrf-cors).                                                                                |
| `405` with a method you did not expect                           | The action declares `http: { method: "GET" }`. Send args as query params, not a JSON body.                                                                                        |
| `404`                                                            | Wrong action id, an `http: { path: "..." }` override, or `http: false`.                                                                                                           |

## What's next {#related-docs}

- [**Actions**](/docs/actions-overview) — define the operation once; HTTP, MCP, A2A, CLI, and the UI all call the same one
- [**Authentication**](/docs/authentication) — session auth, orgs, and static `ACCESS_TOKEN` bearers
- [**MCP Server**](/docs/mcp-protocol) — the tool-catalog surface these tokens are audience-bound to
- [**A2A Protocol**](/docs/a2a-protocol) — agent-to-agent delegation between agent-native apps
- [**Security**](/docs/security) — data scoping, access guards, and secret handling
