Skip to content

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.

WhoExampleHow it signs inIt acts as
Org agentYour own bookkeeping agent or automationOAuth client_credentials with a client secret or a private key JWTThe agent: a role at a scope, like a member
Installed appAn AI accounting firm's integrationAuthorization code with PKCE; an owner or admin installs it on the consent screenThe installation, attributed to the person who installed it
MCP client acting for youClaude or another MCP clientMCP authorization: it registers itself, you sign in and consentYou, 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. 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 (requests per second, changes per day, amounts per financial operation) and the MCP tool sets it sees: workflows (the workflow tools), 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:

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

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

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, 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): 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: 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 and app_installations.update. They are checked on every call, on top of your approval policy:

LimitWhat it countsAbove it
rpsOperation calls per second, on the real clock, in both modes: reads and dry runs tooagent_rate_limited (429, retry_after_ms)
mutationsPerDayChanges per UTC day and mode. Reads, dry runs, idempotent replays and approval requests don't countagent_daily_limit_reached until midnight UTC
amountLimitsPer financial operation and currency (perCall, perDay): the amounts moved, summed per UTC day and modeAn 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 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, so reads aren't in it. Read it with agents.activity or app_installations.activity (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 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 and Building an agent.

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

PartValue
Header typdpop+jwt
Header algES256, Ed25519 or EdDSA
Header jwkYour public key
jtiA new random ID for every proof (each one is accepted once)
htmThe request's HTTP method, such as GET or POST
htuThe request's URL without query or fragment, such as https://ownrecord.io/api/v1/tasks
iatNow, in seconds. Proofs more than 60 seconds from Ownrecord's clock are refused
athOn 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:

TypeScript
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. A missing, malformed or reused proof, or one for another method, URL, key or token, answers 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. 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, agents.update, apps.create or apps.update). An MCP client registering itself asks for the same with dpop_bound_access_tokens: true (RFC 9449 §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

HeaderWhen
Authorization: Bearer or_at_…Every call with a bearer token
Authorization: DPoP or_at_…, DPoPEvery call with a token bound to a key: the token, and a new proof in the DPoP header (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 liveThe mode to act in; test when absent. API keys carry their own mode
Ownrecord-Entity, Ownrecord-BrandTo act on one entity or brand, as with an org API key

A refused token answers 401 with oauth_token_invalid; a token covering several organizations without Ownrecord-Org answers org_required, and a paused app app_installation_paused. DPoP refusals are described above.

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.