# Action endpoints (/docs/run/action-endpoints)



Your tools run in your app, behind one HTTP route. The Runtime POSTs each
**Action** (a tool call, a hook, or a flow `fn`, `verify`, or tool step) to
the URL your app registers, and records the answer as the Action's outcome.
That URL is the agent's **Action endpoint**.

The generated `src/main.ts` serves one:

```ts title="src/main.ts"
import { createServer } from "node:http";
import { createActionHandler } from "@nylorun/agents";
import { agents } from "../agents/index.js";

const port = Number(process.env.PORT ?? 3001);
const url = process.env.NYLORUN_ACTIONS_URL ?? `http://localhost:${port}/nylorun/actions`;

const actions = createActionHandler({ agents, url });
const server = createServer(actions.node);
await new Promise<void>((resolve) => server.listen(port, resolve));
await actions.register({ url });
```

`actions.node` serves `node:http` and Express. `actions.fetch` is a
web-standard handler for Hono, Next.js route handlers, Workers, Bun, and Deno.
`register({ url })` saves the definitions, registers the URL for every served
agent, and pings each one through the Runtime, so a wrong URL fails at
startup. Sandbox tools run inside the Runtime instead, so sandbox-only agents
need no endpoint.

## Reachability [#reachability]

The Runtime must reach the URL:

* **Local stack.** The Runtime runs in Docker and maps `localhost` to your
  machine, so `http://localhost:3001/nylorun/actions` works.
* **Production.** Register a URL the Runtime's machine can reach, and set
  `NYLORUN_ACTIONS_URL` to it.
* **Remote Runtime, local app.** Use a tunnel such as ngrok or Cloudflare
  Tunnel.

A Runtime that refuses private addresses refuses `localhost` URLs. Redirects
are not followed.

## A delivery [#a-delivery]

The Runtime sends `POST <url>` with:

| Part                | Value                                                                      |
| ------------------- | -------------------------------------------------------------------------- |
| Body                | `{ "type": "action", "action": {…}, "sandbox": boolean }`                  |
| `Nylorun-Signature` | A delivery token for this Tenant, URL, Action, generation, and exact body. |
| `Idempotency-Key`   | The Action id.                                                             |

The handler verifies the token before any code runs, then runs your
implementation with the request's signal as `context.signal`. Deliveries of
one Action never overlap. The Runtime settles the Action from the answer:

| Answer                                         | Result                                                                                                   |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `200` with `Nylorun-Outcome: 1`                | The tagged outcome. `createActionHandler` always answers this way.                                       |
| Plain `200`                                    | A tool's output, or what an `fn` or `verify` returned. The tool's output schema still applies.           |
| `202`                                          | The result comes later. See [Long-running tools](#long-running-tools).                                   |
| `429`, `503`, `409`, or never sent             | Retried with backoff from 250 ms to 30 s, honouring `Retry-After`. Reported as `action.delivery_failed`. |
| Any other `4xx`, or a `3xx`                    | The Action fails with `endpoint.rejected`.                                                               |
| No answer after sending: timeout, reset, `5xx` | Lost. A tool becomes `uncertain`; a hook, `fn`, or `verify` is delivered again.                          |

The handler answers `404` for an agent or tool it does not serve, `409` for a
flow Action from another version of the flow, and `503` for a token signed
with a key it has not seen yet, which the Runtime retries.

## Credentials [#credentials]

`register()` uses the application key to call `PUT /v1/endpoints`. Serving
deliveries needs no key: each delivery carries its own short-lived token,
signed with the Tenant's key. A process that only serves Actions can read the
Tenant's public keys without a credential:

```ts
const actions = createActionHandler({
  agents,
  url: "https://app.example.com/nylorun/actions",
  runtime: {
    url: process.env.NYLORUN_RUNTIME_URL!,
    tenant: process.env.NYLORUN_TENANT!,
  },
});
```

Register from a deploy step that holds the application key instead. A
delivery token authorizes only its own Action's heartbeat, result, and sandbox
calls. It cannot act for a subject and is refused from browsers.

## Options [#options]

`register()` takes `timeoutMs`, how long one inline delivery may take (default
60 s, at most 840 s), and `maxConcurrent`, the in-flight deliveries per agent
(default 16). `implementationVersion` defaults to
`NYLORUN_IMPLEMENTATION_VERSION`, then `dev`. All options are on
[createActionHandler](/docs/reference/agents/action-handler).

## Long-running tools [#long-running-tools]

Mark a tool that may outlast `timeoutMs` with `background: true`:

```ts
const buildReport = tool({
  name: "build_report",
  input: z.object({ month: z.string() }),
  background: true,
  async run({ month }, context) {
    return await reports.build(month, { signal: context.signal });
  },
});
```

The handler answers `202` at once, runs the tool, heartbeats with
`POST /v1/actions/:id/heartbeat`, and posts the outcome to
`POST /v1/actions/:id/result`. A heartbeat answered `409` (cancelled, lost, or
delivered again) aborts `context.signal`, and nothing is posted. A delivery
that stops heartbeating is lost at its deadline. On platforms that end work
with the response, pass `waitUntil` to `createActionHandler`.

## Check an endpoint [#check-an-endpoint]

```sh
npx @nylorun/cli tenant endpoints               # each agent's endpoint and health
npx @nylorun/cli tenant endpoints ping <agent>  # send a signed ping through the Runtime
```

Health records the last delivery, last success, last error, consecutive
failures, and what the last ping reported. Sessions record each delivery as
`action.delivered`, and each failed attempt as `action.delivery_failed`; Studio
shows both.

## Retries and uncertain work [#retries-and-uncertain-work]

Stopping a Worker or closing a Tenant does not cancel the interrupted turn;
the next advance resumes from its checkpoint. Takeover marks in-flight effects
`uncertain`. Only user cancellation ends the turn as `cancelled`; it aborts the
request, and because the code may have run, the Action becomes `uncertain`.
The Runtime never repeats a tool whose delivery was lost. Hooks, `fn`, and
`verify` may be delivered again, so keep irreversible side effects in tools
and make them idempotent with `context.idempotencyKey`.

<Callout title="Replaces executors">
  Executors, `connectAgents`, `NYLORUN_EXECUTOR_KEY`, `/v1/executors`, claims,
  and the `action_result` command were removed in protocol 3. Mount
  `createActionHandler` and call `register({url})` instead. Upgrade the Runtime
  and SDK together. See [Compatibility](/docs/compatibility#breaking-changes).
</Callout>

## Next step [#next-step]

<Cards>
  <Card title="Deploy your app server" description="Serve the Action endpoint with explicit Runtime settings." href="/docs/deploy/app-server#action-endpoint" />

  <Card title="Events" description="Read history and stream live progress." href="/docs/run/events" />
</Cards>
