# Agents and apps

> Machines that act in your organization through OAuth 2.1, your own agents with client credentials, partner apps you install, and MCP clients acting for a person, always with no more access than a person gave them.

Besides API keys, three kinds of machines can act in your organization. All of them get short-lived **OAuth access tokens** from Ownrecord's authorization server at `https://ownrecord.io/oauth`, and every call they make goes through the same operations, permissions, approvals and audit log as the console.

| Who                           | Example                                  | How it signs in                                                                   | It acts as                                                  |
| ----------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| **Org agent**                 | Your own bookkeeping agent or automation | OAuth `client_credentials` with a client secret or a private key JWT              | The agent: a role at a scope, like a member                 |
| **Installed app**             | An AI accounting firm's integration      | Authorization code with PKCE; an owner or admin installs it on the consent screen | The installation, attributed to the person who installed it |
| **MCP client acting for you** | Claude or another MCP client             | MCP authorization: it registers itself, you sign in and consent                   | You, in the organization you chose                          |

## What a token can do

A call made with an access token is allowed only when every one of these allows it, checked again on every call:

1. the **scopes** the token was granted, such as `filings:write` or `customers:read` (each operation's reference page names the permission it needs);
2. the **role and scope** of the agent, or the **current memberships** of the person who installed the app or who signed in. If that person loses access, so does the app;
3. an installation's **limits** to some legal entities or brands (`limitTo`);
4. your plan's **entitlements**, and your **approval policy** for the operation's risk tier.

Some things are never available to a token: deciding approval requests (approvals are always a person in the console), the customer portal, the platform console, and creating organizations or accepting invitations.

## Org agents

Create agents under **Organization → Agents & apps**, or with [`agents.create`](https://ownrecord.io/docs/reference/agents-create.md). Each agent:

- holds one role (any role but `org_owner`) at the organization, one legal entity or one brand;
- gets a `client_id` and either a client secret, shown once, or your public keys for `private_key_jwt`;
- works in **test mode only** until an owner or admin turns on live mode for it;
- has [limits](#limits) (requests per second, changes per day, amounts per financial operation) and the MCP tool sets it sees: `workflows` (the [workflow tools](https://ownrecord.io/docs/mcp/workflows.md)), `registry` (one tool per operation) or single areas such as `tasks`. Without a choice it sees every tool; people's MCP clients and installed apps always do. Tool sets change what the agent sees, never what it may do.

It asks for a token at `/oauth/token`:

```bash
curl -s https://ownrecord.io/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials
```

The answer is an opaque access token, `or_at_…`, valid for 15 minutes. Without a `scope` parameter the token gets every scope the agent may use. An agent never gets more than the person who created it has at its scope and mode. Send a `DPoP` proof with the request to get a token that only works together with your agent's key ([DPoP](#sender-constrained-tokens-dpop)).

**Pause** an agent to revoke every token it holds at once; it can't get new ones until you resume it. **Revoke** it to stop it for good. See [Pause and revoke](#pause-and-revoke) for what happens to calls in flight and to its approval requests.

## Apps

Any organization can register an app under **Developers → Apps** (on the same page, or with [`apps.create`](https://ownrecord.io/docs/reference/apps-create.md)). Private apps install only in the organization that registered them; listed apps can be installed by other organizations once platform review has approved them, and then show a **verified publisher**. To list an app, its organization accepts the Partner and App Terms and submits an application: publisher identity, privacy policy, a security questionnaire and a justification for each scope ([List your app for every organization](https://ownrecord.io/docs/guides/listing-an-app.md)).

Other organizations can grant a listed app only the scopes its review approved. When an approved app asks for more, those scopes are reviewed again: meanwhile the app stays listed for the approved ones, and installations keep the scopes they consented to until the new ones are approved and consented.

An owner or admin installs an app through the **consent screen**, which shows the app and its publisher (verified or not), the permissions it asks for in plain words grouped by risk tier, the organization (and optionally the entities or brands) it may act in, and the approval policy that applies. Installing an app instructs Ownrecord, as your processor, to disclose the data those permissions cover to the app's publisher; whether the publisher is your processor or a controller is agreed between you and them.

The app then acts as the **installation**, attributed to the person who installed it and limited to their access. Its refresh tokens rotate on every use; a reused refresh token revokes them all.

- **Pause** an installed app to stop its tokens at once. After you resume it, it must ask for authorization again.
- If the person who installed it **leaves** the organization, the installation pauses and your owners are asked to **re-assign** it to another owner or admin. Calls then resume with the same tokens, attributed to the new installer.
- **Uninstall** it to remove its access; it can be installed again later.
- If its publisher **disables** the app ([`apps.disable`](https://ownrecord.io/docs/reference/apps-disable.md), for good), its tokens and those of the agents it owns are revoked in every organization, your installation is paused (`pausedReason: app_disabled`) and your owners and admins get the `APP_DISABLED` alert. It can't be resumed or installed again ([`oauth_client_disabled`](https://ownrecord.io/docs/reference/errors.md#oauth_client_disabled)): uninstall it.

### Agents an app owns

An app can also act through an **agent it owns** in your organization: an agent like your own (a role and scope, client credentials, limits, test mode until live is turned on), listed under **Organization → Agents & apps** as "Agent of the app …", whose page shows the app and who installed it. The app asks for one with [`app_installations.create_agent`](https://ownrecord.io/docs/reference/app-installations-create-agent.md): the request always waits in your approvals inbox, and an owner or admin other than the app's installer approves it before the agent exists. It never gets more than the app was granted, and it signs in with the app's private key (`private_key_jwt`). Its calls are attributed to the agent, **the app's installation and the person who installed it**, like the app's own calls:

- the installer stands behind its approval requests: they can withdraw one but someone else approves it, and they aren't emailed about it;
- it stops when the installation does: pausing or uninstalling the app revokes the agent's tokens too, and it gets no new ones until the app is resumed;
- it stops with the app: once the app is disabled by its publisher or suspended by Ownrecord, the agent's tokens are refused and it gets no new ones;
- if the installer leaves the organization, the agent's calls are refused and the installation pauses until your owners re-assign it.

## Limits

Every org agent and installed app can have limits, set on its page under **Organization → Agents & apps** (owners and admins), or with [`agents.update`](https://ownrecord.io/docs/reference/agents-update.md) and [`app_installations.update`](https://ownrecord.io/docs/reference/app-installations-update.md). They are checked on every call, on top of your [approval policy](https://ownrecord.io/docs/concepts/approvals.md):

| Limit             | What it counts                                                                                             | Above it                                                                                                                  |
| ----------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `rps`             | Operation calls per second, on the real clock, in both modes: reads and dry runs too                       | [`agent_rate_limited`](https://ownrecord.io/docs/reference/errors.md#agent_rate_limited) (`429`, `retry_after_ms`)        |
| `mutationsPerDay` | Changes per UTC day and mode. Reads, dry runs, idempotent replays and approval requests don't count        | [`agent_daily_limit_reached`](https://ownrecord.io/docs/reference/errors.md#agent_daily_limit_reached) until midnight UTC |
| `amountLimits`    | Per financial operation and currency (`perCall`, `perDay`): the amounts moved, summed per UTC day and mode | **An approval request instead of running** (`202` with the approval), never a silent run or a refusal                     |

How amount limits work, with a limit of €200 per day on `refunds.create`:

- a refund of €120 runs, and so does one of €80: the day's sum is €200;
- the next refund, even of €1, answers `approval_required`: a person sees it in the approvals inbox, with the reason ("above the agent's daily limit") and the dry run's effect;
- once approved, the refund runs as the agent requested it, attributed to the agent and the approver, and counts in the day's sum;
- a call **without an amount** (for example a full refund, which names no amount) or in a currency no limit covers also needs approval: send the amount when your agent can.

Amounts are compared as integers in micros of their currency, never rounded, and two calls at the same moment never pass a limit together. A call that fails gives what it counted back. Each mode counts on its own: test-mode refunds never use up the live limit. A [dry run](https://ownrecord.io/docs/concepts/approvals.md) counts nothing; it answers `agent_daily_limit_reached` when the real call would.

Agents work in **test mode only** until an owner or admin turns on live mode for them; an installed app uses live mode only when its installer may. API keys, and people acting through an MCP client, have no such limits: keys follow their scopes and your organization's API rate limit.

## Agent activity

Each agent's and app's page shows its **activity**: the changes it made, the calls that asked for approval instead of running (with the approval's current status and who approved it), and the runs a person approved, newest first, with the operation, what it changed, the mode, and the OAuth client it came through. For an app, every entry names the member it acted for; for an agent an app owns, the app and that member. The page also shows today's use of its daily limits.

The feed comes from the [audit log](https://ownrecord.io/docs/concepts/audit-log.md), so reads aren't in it. Read it with [`agents.activity`](https://ownrecord.io/docs/reference/agents-activity.md) or [`app_installations.activity`](https://ownrecord.io/docs/reference/app-installations-activity.md) (`audit:read`: owners and admins).

## Pause and revoke

- **Pausing** an agent or an installed app revokes every token it holds at once. A call that was already accepted when you paused it is refused at its next step with `oauth_token_invalid`, its [event stream](https://ownrecord.io/docs/concepts/events.md) on the MCP server ends at the next look, and its pending approval requests can no longer run: approving one marks it **stale**.
- **Resuming** gives access back with **fresh tokens only**: an agent asks `/oauth/token` again, an app asks for authorization again. Old tokens stay revoked.
- **Revoking** stops an agent or uninstalls an app for good.

An approved request always runs as the requester is **now**: if the agent or app was paused, revoked or uninstalled, if the person who installed the app lost access, or if a person disconnected the MCP client they used, the approval goes stale instead of running.

## MCP clients

The MCP server at `https://ownrecord.io/mcp` follows the MCP authorization spec. A client without a token gets `401` with `WWW-Authenticate: Bearer resource_metadata="https://ownrecord.io/.well-known/oauth-protected-resource/mcp"`. From there it discovers the authorization server (`/.well-known/oauth-authorization-server`), registers itself (dynamic client registration), and sends you to sign in and consent. You choose one of your organizations and the permissions; the client then acts **as you** there, with at most your own role, and can never approve requests.

Org API keys also work on the MCP server, for headless agents. Besides its tools, the server offers resources (a task's packet, package and playbook, report files, invoice PDFs, the event feed, these docs) and prompts for guided jobs; see the [MCP reference](https://ownrecord.io/docs/mcp.md) and [Building an agent](https://ownrecord.io/docs/guides/building-an-agent.md#mcp-setup-example).

## Sender-constrained tokens (DPoP)

An access token is a bearer token unless you ask for more: whoever holds it can use it until it expires. If your agent, app or MCP client can sign requests, bind its tokens to a key with **DPoP** ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)). A token copied from a log, a proxy or a crash report is then useless without the private key, which never leaves your client.

1. Create a key pair (ES256 or Ed25519) when the client starts, and keep the private key in memory or your secret store.
2. Ask `/oauth/token` for tokens as usual (client credentials, authorization code or refresh token), adding a `DPoP` header with a proof for that request. The answer says `"token_type": "DPoP"`: the access token is bound to your key.
3. Call the API and the MCP server with `Authorization: DPoP or_at_…` and a **new proof on every request** in the `DPoP` header.

A proof is a short JWT signed with your private key:

| Part         | Value                                                                                               |
| ------------ | --------------------------------------------------------------------------------------------------- |
| Header `typ` | `dpop+jwt`                                                                                          |
| Header `alg` | `ES256`, `Ed25519` or `EdDSA`                                                                       |
| Header `jwk` | Your **public** key                                                                                 |
| `jti`        | A new random ID for every proof (each one is accepted once)                                         |
| `htm`        | The request's HTTP method, such as `GET` or `POST`                                                  |
| `htu`        | The request's URL without query or fragment, such as `https://ownrecord.io/api/v1/tasks`            |
| `iat`        | Now, in seconds. Proofs more than 60 seconds from Ownrecord's clock are refused                     |
| `ath`        | On API and MCP calls: the base64url-encoded SHA-256 of the access token. Not sent to `/oauth/token` |

With Node.js and the `jose` library:

```ts
import { createHash, randomUUID } from "node:crypto";
import { exportJWK, generateKeyPair, SignJWT } from "jose";

const { privateKey, publicKey } = await generateKeyPair("ES256");
const jwk = await exportJWK(publicKey);

function proof(method: string, url: string, accessToken?: string): Promise<string> {
  return new SignJWT({
    jti: randomUUID(),
    htm: method,
    htu: url,
    ...(accessToken && { ath: createHash("sha256").update(accessToken).digest("base64url") }),
  })
    .setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk })
    .setIssuedAt()
    .sign(privateKey);
}

const tokenUrl = "https://ownrecord.io/oauth/token";
const token = await fetch(tokenUrl, {
  method: "POST",
  headers: {
    authorization: `Basic ${btoa(`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`)}`,
    dpop: await proof("POST", tokenUrl),
  },
  body: new URLSearchParams({ grant_type: "client_credentials" }),
}).then((response) => response.json()); // token.token_type === "DPoP"

const url = "https://ownrecord.io/api/v1/tasks";
const tasks = await fetch(url, {
  headers: {
    authorization: `DPoP ${token.access_token}`,
    dpop: await proof("GET", url, token.access_token),
    "Ownrecord-Mode": "test",
  },
});
```

**Refresh tokens.** A public client's refresh token (an MCP client or a native app without a client secret) is bound to the same key: refreshing it needs a proof from that key, it rotates on every use, and the new one stays bound. A confidential client's refresh token is already protected by its client secret; each refresh with a proof returns an access token bound to the proof's key.

**What gets refused.** A bound token sent as `Authorization: Bearer` answers [`dpop_proof_required`](https://ownrecord.io/docs/reference/errors.md#dpop_proof_required). A missing, malformed or reused proof, or one for another method, URL, key or token, answers [`dpop_proof_invalid`](https://ownrecord.io/docs/reference/errors.md#dpop_proof_invalid), whose message says which check failed. A bearer token (issued without a proof) sent with the `DPoP` scheme, or a bound token with a proof from another key, answers [`oauth_token_invalid`](https://ownrecord.io/docs/reference/errors.md#oauth_token_invalid). All of these are `401` with a `WWW-Authenticate: DPoP` challenge listing the accepted algorithms.

DPoP is chosen per request: tokens asked for without a proof stay bearer tokens, so clients without DPoP keep working.

**Requiring DPoP.** A client that always uses DPoP can make it mandatory, so a token is never issued unbound: turn on **Require DPoP** for an agent or one of your apps in **Organization → Agents & apps** (or send `dpopBoundAccessTokens: true` with [`agents.create`](https://ownrecord.io/docs/reference/agents-create.md), [`agents.update`](https://ownrecord.io/docs/reference/agents-update.md), [`apps.create`](https://ownrecord.io/docs/reference/apps-create.md) or [`apps.update`](https://ownrecord.io/docs/reference/apps-update.md)). An MCP client registering itself asks for the same with `dpop_bound_access_tokens: true` ([RFC 9449 §5.2](https://www.rfc-editor.org/rfc/rfc9449#section-5.2)); the registration response confirms it. From then on `/oauth/token` refuses the client's token requests without a proof (`invalid_grant`). Tokens issued before keep working until they expire.

Ownrecord doesn't send DPoP nonces. The authorization server and every protected resource list the accepted algorithms in `dpop_signing_alg_values_supported` in their metadata. API keys are always bearer tokens: keep them on your servers.

## Headers for OAuth tokens

| Header                                | When                                                                                                                                                    |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Authorization: Bearer or_at_…`       | Every call with a bearer token                                                                                                                          |
| `Authorization: DPoP or_at_…`, `DPoP` | Every call with a token bound to a key: the token, and a new proof in the `DPoP` header ([DPoP](#sender-constrained-tokens-dpop))                       |
| `Ownrecord-Org: org_…`                | When the token covers several organizations (an app installed by the same person in several, or an MCP client connected to several); otherwise optional |
| `Ownrecord-Mode: test` or `live`      | The mode to act in; `test` when absent. API keys carry their own mode                                                                                   |
| `Ownrecord-Entity`, `Ownrecord-Brand` | To act on one entity or brand, as with an org API key                                                                                                   |

A refused token answers `401` with [`oauth_token_invalid`](https://ownrecord.io/docs/reference/errors.md#oauth_token_invalid); a token covering several organizations without `Ownrecord-Org` answers [`org_required`](https://ownrecord.io/docs/reference/errors.md#org_required), and a paused app [`app_installation_paused`](https://ownrecord.io/docs/reference/errors.md#app_installation_paused). DPoP refusals are described [above](#sender-constrained-tokens-dpop).

## Attribution

The audit log names who acted: the agent (`agt_…`) and its OAuth client, or the installation (`ains_…`), its client and the installer, or the person and the client they used. An agent an app owns adds the installation (`appInstallationId`) and the installer (`onBehalfOfUserId`). An approval adds the approver: a run a person approved carries the approval's ID (`approvalId`), and the approval names who approved it. So the submission of a return by an app's agent, approved by a member, shows all four: the agent, the app, the person who installed it and the approver.
