Dyrra API v1

New here? Start with the quickstart: your agent's first click in 10 minutes.

One API to drive any Mac for AI agents. A user's connected Mac and a cloud Mac are called the same way — only {target} changes.

Base URL: https://api.dyrra.dev/v1 · Specification: GET /v1/openapi.json


Authentication

Authorization: Bearer dk_live_…   # live key
Authorization: Bearer dk_test_…   # test key

A key is shown once, when it is created. We store only its HMAC-SHA256. Every response carries an X-Request-Id header — include it when you contact support.

Key format

A key is dk_live_ or dk_test_ followed by 60 characters [0-9A-Za-z]: 54 characters of randomness (40 random bytes) and a 6-character checksum (CRC32 of everything before it, in base62). The checksum is not a secret — it only catches typos:

Keys issued before September 2026 are shorter (dk_live_ + 43 characters, no checksum). They keep working; new keys always use the format above.


Connect an agent (MCP)

@dyrra/mcp is a standard MCP server over stdio, started with npx -y @dyrra/mcp. It needs two environment variables: DYRRA_API_KEY and DYRRA_TARGET (a con_… connection or a cloud Mac id). The console's MCP page gives a ready config with both filled in for each agent:

Agent How it is added
Claude Code claude mcp add --env … --scope user --transport stdio dyrra -- npx -y @dyrra/mcp
Cursor ~/.cursor/mcp.json, key mcpServers
VS Code code --add-mcp '{"name":"dyrra","type":"stdio",…}'
OpenClaw openclaw mcp set dyrra '{"command":"npx",…}'
Hermes ~/.hermes/config.yaml, key mcp_servers, then /reload-mcp
Codex CLI codex mcp add dyrra --env … -- npx -y @dyrra/mcp
Any other MCP client JSON with mcpServers

The server registers only the tools the connection's permissions allow.

Optional variables: DYRRA_AGENT_NAME — the name shown in the console (by default it comes from the MCP client: Claude Code, Codex, Cursor…), and DYRRA_TASK — the agent's task. The agent can also set its task itself with mac_task.

Every POST supports Idempotency-Key (kept for 24 hours).


Several agents on one computer

A computer has one screen, one mouse, one keyboard and one clipboard. Several agents can still work with it: one at a time holds the turn and the others wait in line, in the order they came. The turn covers a whole "look → act → check" sequence, not a single click.

Each running agent is an agent session. The client picks an id (as_ and 16–64 letters or digits) and sends it in the Dyrra-Agent-Session header with every request; @dyrra/mcp and the SDK do it for you (new Dyrra(key, { agentSession: newAgentSessionId() })).

POST /v1/agent-sessions
{ "id": "as_…", "client": "claude-code 2.1.0", "task": "Testing the signup form" }
→ { "id": "as_…", "name": "Claude Code", "status": "working", "paused": false, … }

Send it every 30 s while the agent runs: the console tells a working agent from a lost one. DELETE /v1/agent-sessions/{id} ends the session and passes its turn on at once. Requests without the header still work: everything made with one API key counts as one agent named after the key.

The console's Live agents page shows each agent with its task, computer and status — working, in line, waiting for the person, connected, paused, lost — and lets the person pause one agent (409 agent_paused) or all of them. Other agents keep working while one is paused.


Coordinates come first

All screen coordinates in the API are logical points of the main display, not Retina pixels.

By default a screenshot is scaled down to its size in points, so one image pixel equals one screen point, and coordinates taken from the image can be passed to actions as they are.

If you ask for maxWidth, the response includes scale = imageWidth / widthPoints. Divide a coordinate taken from such an image by scale:

const shot = await mac.screenshot({ maxWidth: 1280 });
// the model pointed at (640, 400) in image pixels
const x = 640 / shot.scale;
const y = 400 / shot.scale;
await mac.actions([{ type: "click", x, y }]);

The @dyrra/sdk adapters (fromAnthropicAction, fromOpenAIAction) do this conversion themselves when you pass them { scale }.

Coordinates outside the screen → 400 invalid_request.


Connection permissions

Permission What it allows Default
screen Screenshots, the screen tree, the window list required
input Mouse, keyboard, opening apps and links pre-selected
shell Terminal commands off
files Reading and writing in folders the user chose off
clipboard The clipboard off
record Sharing session recordings with the platform off

A platform requests permissions in the link session, but the user may grant fewer. The stricter set wins. Permissions are enforced both on our server and in the app on the Mac.


Connecting a Mac

POST /v1/end-users
{ "externalId": "user_123" }
→ 201 { "id": "eu_…", … }

POST /v1/link-sessions
{ "endUserId": "eu_…", "scopes": ["screen", "input"], "redirectUrl": "https://…" }
→ 201 { "id": "lks_…", "url": "https://link.dyrra.dev/l/lks_…#token", "code": "7K4Q-9XPM",
        "expiresAt": "…" }

Send the url to your user. They open it, the Dyrra Agent app shows a consent screen, and once they approve you receive a connection.created webhook with the connectionId. The link is valid for 30 minutes.

One Mac serves only one of your users. Linking the same Mac to another endUserId of the same platform returns machine_already_linked_to_other_user. This is a requirement of the macOS license, not our own restriction.


Mac status

GET /v1/connections/{connectionId} returns in machine:

Field Meaning
online whether the app is connected right now
availability ready, asleep, screen_locked, no_gui_session — or unknown when the Mac is offline
lastSeenAt when the Mac was last online; null if never
paused pause state as of the last contact
os macos, windows or linux. Pick shortcuts by it: cmd on macOS, ctrl elsewhere

Without a connection the current state is unknown, so availability is unknown rather than the last remembered value.

availability describes the Mac, not the permissions. The connection's permissions are in the scopes field of the same response. A connection with only screen sees the screen while availability is "ready", but every mouse or keyboard action gets 403 scope_denied. Check scopes before you start.

Permissions live on the Mac. grantedOnMac in the same response says whether the app on the Mac itself holds this connection's permissions. false means they were reset there — the app was reinstalled, or updated from version 0.1.8 or older — and every action is refused with 403 scope_denied, details.reason: "not_granted_on_mac". Only the owner can fix it: by reconnecting the Mac (a new link session for the same Mac keeps the same connectionId). null means unknown: the Mac is offline or its app is older than 0.1.10.

The mac_status MCP tool puts it all together: canSee, canControl, the reasons why not, and the granted permissions with what each one allows.


Control

{target} is a connectionId (con_…) for a connected Mac or a cloudMacId (cmc_…) for a cloud one.

Screen tree — start here

POST /v1/targets/{target}/ui-tree
{ "scope": "frontmost", "interactiveOnly": true, "format": "compact" }
→ { "snapshotId": "snp_…",
    "app": { "bundleId": "com.apple.TextEdit", "name": "TextEdit" },
    "compact": "[e12] button \"Save\" (812,640 96x28)\n[e13] textfield \"Search\" …",
    "elements": [ … ], "truncated": false }

Cheaper and more precise than a screenshot: the agent clicks an element instead of guessing pixels. A snapshot lives for 30 seconds, then 410 snapshot_expired.

Password field values (AXSecureTextField) are never returned.

Actions

POST /v1/targets/{target}/actions
{ "actions": [
    { "type": "click_element", "snapshotId": "snp_…", "elementId": "e12" },
    { "type": "type", "text": "Hello" },
    { "type": "key", "keys": ["cmd", "s"] }
  ],
  "screenshotAfter": { "maxWidth": 1280 } }
→ { "ok": true, "screenshot": { … } }

Up to 50 actions per batch. On an error the batch stops and returns failedIndex. Only one batch runs on a Mac at a time: a second batch waits for the first. Another agent waits for its turn — see Several agents on one computer.

Action types: move, click, mouse_down, mouse_up, drag, scroll, type, key, wait, click_element, set_value.

Modifiers: cmd, shift, alt, ctrl, fn, win. cmd exists only on macOS and win only on Windows; a computer refuses a modifier its system lacks rather than swapping it silently. Choose by machine.os.

Typing into the right app. Text and keys go to the app in front. If focus has not switched yet, the text lands in someone else's window. Pass expectBundleId — the Mac checks the app in front before every action of the batch and stops the batch if another app is there:

POST /v1/targets/{target}/actions
{ "actions": [ { "type": "type", "text": "Hello" } ],
  "expectBundleId": "com.apple.Notes" }
→ 409 { "error": { "code": "app_not_frontmost",
                   "details": { "expected": "com.apple.Notes",
                                "actual": "com.anthropic.claudefordesktop",
                                "actualName": "Claude", "failedIndex": "0" } } }

Before the first action the Mac waits up to 2 seconds for the app to come to the front; before the others it checks right away. The MCP server fills in expectBundleId for mac_type and mac_key itself — it is the app last opened with mac_open_app.

Screenshot

POST /v1/targets/{target}/screenshot
{ "format": "jpeg", "quality": 80, "maxWidth": 1280 }
→ { "mime": "image/jpeg", "data": "…base64…",
    "imageWidth": 1280, "imageHeight": 831,
    "widthPoints": 1512, "heightPoints": 982, "scale": 0.846 }

Screenshots are not blocked by a control session and are never stored on our server.

Terminal, files, native actions

POST /v1/targets/{target}/shell        { "command": "xcodebuild -list", "timeoutMs": 60000 }
GET  /v1/targets/{target}/files?path=…
PUT  /v1/targets/{target}/files?path=…
POST /v1/targets/{target}/apps/open    { "bundleId": "com.apple.Safari" }
GET  /v1/targets/{target}/apps
GET  /v1/targets/{target}/windows
POST /v1/targets/{target}/urls/open    { "url": "https://…" }
GET  /v1/targets/{target}/clipboard
PUT  /v1/targets/{target}/clipboard    { "text": "…" }

apps/open and apps/activate answer only once the app is actually in front (they wait up to 5 seconds) and return { "ok": true, "bundleId": "…", "name": "…" }. If it did not come to the front — 409 app_not_frontmost, and you should not type into it.

click_element checks that it will land where it promised. Before clicking, the Mac checks that the snapshot's app is in front and that the same element is under the point. If the window moved — 410 snapshot_expired, not a silent success. If another app is in front — 409 app_not_frontmost.

Confirmations

Dangerous actions need the person's approval by default: sending a message, paying, deleting, answering another app's dialog. What matters is the consequence, not the method — ⌘⇧D in Mail sends an email just like the Send button, so it asks too. The Mac checks known dangerous shortcuts for the app in front, Return or Space while another app's dialog is open, and Return while focus is on a send button. Terminal commands and deleting files also ask.

The command runs only after the person clicks Allow on the Mac, so the answer may take a while. Denied → 403 denied_by_user, no answer → 408 confirmation_timeout. The person can relax the policy per connection.

Dyrra's own interface is off limits to the agent. The confirmation window, the activity frame and the badge never appear in a screenshot, in ui_tree or in the window list, and a click into them is refused with 409 dyrra_ui_protected. Approval is the person's decision; the agent cannot press Allow for them.


When the Mac is unavailable

Add whenUnavailable: "queue" and ttlSeconds (up to 86,400) to the request:

POST /v1/targets/{target}/actions
{ "actions": [ … ], "whenUnavailable": "queue", "ttlSeconds": 3600 }
→ 202 { "commandId": "cmd_…", "status": "queued", "expiresAt": "…" }

GET    /v1/commands/{commandId}
DELETE /v1/commands/{commandId}      # while the command is queued

These can be queued: actions, shell, apps/open, apps/activate, urls/open and writing the clipboard (PUT clipboard). Screenshots, ui-tree and reading the clipboard cannot: they only make sense in the moment.

How a command reaches the Mac:

  1. The Mac comes online, wakes up or is unpaused.
  2. The app shows a notification — "{Platform}: N tasks were waiting for the Mac to wake up" — and acts on the owner's setting: ask (default), run right away, or don't run.
  3. Accepted commands run one at a time, in the order they were queued, with all the usual checks: permissions, hidden apps, confirmations for dangerous actions.
Status When
queued waiting for the Mac or for the owner's decision
running running
completed done, command.completed webhook
failed failed, or the owner declined (error.code: denied_by_user), command.failed webhook
expired the term ran out first, command.expired webhook
canceled canceled with DELETE

The result is kept for 24 hours, without screenshots. Terminal output is never stored on the server: for shell the result has only exitCode, durationMs, truncated and timedOut.

If the owner does not answer, that is not a refusal: the command waits until its term ends and is offered again the next time the Mac comes online.

The queue works for connected Macs. A cloud Mac that is offline still returns 409 machine_offline.

Dyrra does not wake a sleeping Mac. Remote wake is not reliable, and we do not promise it.


Cloud Macs

POST /v1/cloud-macs
{ "term": "day", "hardware": "mac_mini_m4", "developerUseAcknowledged": true }
→ 201 { "id": "cmc_…", "status": "awaiting_payment", "hardware": "mac_mini_m4",
        "price": { "amount": 1000, "currency": "usd" },
        "checkoutUrl": "https://app.norelio.eu/p/…?client_reference_id=cmc_…" }

The machine is not handed over right away. The lease is created as awaiting_payment; send your customer to checkoutUrl. The machine is assigned only after the payment is confirmed, with no one on our side involved:

status What it means
awaiting_payment Created, not paid. checkoutUrl works for 24 hours.
queued Paid; we are preparing the machine. readyBy = paidAt + 24 hours. The paid term has not started yet. Calls to the target answer 409 lease_not_active.
ready Handed over. The paid term runs from leaseStartedAt to leaseEndsAt. A cloud_mac.ready webhook arrives and the account owner gets an email.
ended Finished or cancelled before payment.

A lease becomes ready straight away when a machine of the requested model is free and online in our pool; otherwise it waits in queued and gets the first machine of that model that joins the pool, in order of payment.

Extending works the same way: POST /v1/cloud-macs/{id}/extend returns a link, and the term moves on payment — from the end of the paid period, not from "now".

Machines

hardware Where it comes from On order
mac_mini_m4 (default) our pool ready if a machine is free, else queued
mac_mini_m4_pro our pool on request: out of stock at our providers
mac_studio_m1_max built to order queued until the machine is sourced
mac_studio_m2_max built to order on request: out of stock at our providers
mac_studio_m2_ultra built to order queued until the machine is sourced
mac_studio_m4_max built to order on request: out of stock at our providers
mac_studio_m1_ultra built to order on request
mac_studio_m3_ultra built to order on request
mac_pro built to order on request
mac_mini_m5_pro — coming soon
mac_mini_m6 — coming soon
mac_studio_m5_max — coming soon
mac_studio_m5_ultra — coming soon

We never substitute hardware: if the pool has no machine of the requested kind, the lease waits in the queue instead of running on a different machine. We promise a machine within 24 hours of payment; the paid term starts only when it is handed over. Readiness arrives as a cloud_mac.ready webhook.

Memory and storage

Each machine has a base configuration — the one in the price table. More memory or a larger SSD is a surcharge on top of the base price:

POST /v1/cloud-macs
{ "term": "month", "hardware": "mac_studio_m2_ultra", "ramGb": 128,
  "developerUseAcknowledged": true, "contactEmail": "you@example.com" }
→ 202 { "id": "cmq_…", "status": "quote_requested", "hardware": "mac_studio_m2_ultra",
        "spec": { "ramGb": 128, "ssdGb": 2048 }, "estimateCents": 63000, … }

ramGb and ssdGb are optional; leaving them out means the base configuration. An upgraded configuration is ordered as a request with a calculated price, not as a lease: we send you an invoice. A size the machine does not come in → 400 invalid_request with the available sizes. If the provider does not offer that combination, estimateCents is null and the price is on request.

Machine Base configuration Memory options SSD options
Mac mini M4 16 GB / 256 GB 24, 32 GB 512 GB – 2 TB
Mac mini M4 Pro 64 GB / 1 TB — 2 – 8 TB
Mac Studio M1 Max 64 GB / 2 TB — 4 – 8 TB
Mac Studio M2 Max 64 GB / 1 TB 96 GB 2 – 8 TB
Mac Studio M2 Ultra 64 GB / 2 TB 128, 192 GB 4 – 8 TB
Mac Studio M4 Max 64 GB / 1 TB 128 GB 2 – 8 TB
Mac mini M5 Pro 24 GB / 512 GB, 15-core CPU 48, 64 GB 1 – 8 TB
Mac mini M6 16 GB / 256 GB, 12-core CPU 24, 32 GB 512 GB – 2 TB

Mac mini M5 Pro also comes with an 18-core CPU and 20-core GPU: pass "chip": "18-core CPU, 20-core GPU". A chip other than the base one is always a request, like an upgrade.

The console's Cloud Macs page shows every option with its surcharge, or "on request" when it is not offered or not priced yet.

On request and coming soon

What can be leased is decided by the price in our configuration, not by a list in code. If there is a price for the requested term, a lease is created. If not, the request does not create a lease and charges nothing: you get a request id, and a person replies.

POST /v1/cloud-macs
{ "term": "month", "hardware": "mac_pro", "developerUseAcknowledged": true,
  "contactEmail": "you@example.com", "note": "4 machines for CI" }
→ 202 { "id": "cmq_…", "status": "quote_requested", "hardware": "mac_pro",
        "term": "month", "message": "Request received: Mac Pro, month term. …" }

contactEmail and note are optional, but without an email we cannot reply.

Coming soon is a separate state. Apple announced Mac mini M5 Pro and M6 and Mac Studio M5 Max and M5 Ultra on 25 August 2026 and ships them from 22 September 2026; providers don't have them yet. We accept requests for them, and the response says plainly that the machine will be available later.

Prices

Machine Day Week Month
Mac mini M4 $10 $48 $189
Mac mini M4 Pro $20 $97 $385
Mac Studio M1 Max $18 $88 $349
Mac Studio M2 Max $18 $90 $357
Mac Studio M2 Ultra $26 $130 $517
Mac Studio M4 Max $21 $104 $413
Mac Studio M1 Ultra, M3 Ultra, Mac Pro on request on request on request
Mac mini M5 Pro, M6 coming soon coming soon coming soon
Mac Studio M5 Max, M5 Ultra coming soon coming soon coming soon

A month is the best value: a day is a month ÷ 20, a week is a month ÷ 4. The difference covers idle time between leases.

Every machine comes with Xcode and developer tools preinstalled, and the API and MCP ready to use.

Cloud Macs are provided for software development and testing only: building from source, automated tests, developer tools, simulators. Without developerUseAcknowledged: true the request is refused. The minimum lease is 24 hours: ending early does not release the machine sooner than 24 hours and is not refunded.

These are the terms of section 3 of the macOS license. By passing developerUseAcknowledged: true you also agree to Apple's macOS license and the terms of the software on the Mac, such as Xcode. If you give the Mac to your own users, section 3 applies to you as well: each of them must agree to those terms before using it.


Billing for connected Macs

Free for up to 10 active Macs per month. Beyond that, a package: 50, 200 or 1000 Macs.

GET /v1/subscription
→ { "package": "free", "status": "active",
    "machineLimit": 10, "activeMachines": 7,
    "currentPeriodEnd": null, "cancelAtPeriodEnd": false,
    "upgradeUrls": { "p50": "https://app.norelio.eu/p/…", … } }

We bill by package, not by usage: payment goes through an external link, and a link cannot charge a variable amount. When the package is used up, calls to new Macs get 402 with the suggested package:

{ "error": { "code": "payment_required",
  "message": "The free plan allows 10 active Macs per month, 10 are in use. Move to the 50 Mac plan in the console.",
  "details": { "package": "free", "machineLimit": 10, "activeMachines": 10, "suggestedPackage": "p50" } } }

If a payment fails, the subscription becomes past_due, but the limit holds for 7 more days. After that grace period the free package remains.


Session recordings

A video of what agents do on a Mac. Recording is off by default and stays on the Mac (Movies → Dyrra, kept 7 days) unless the owner shares it. Your platform receives recordings only when all three hold:

  1. the owner set Dyrra Agent → Session recording → Share with platforms I allow;
  2. the owner allowed your platform in a separate consent window;
  3. the connection has the record permission.

Cloud Macs are recorded for the platform that leases them, without a separate consent. The app records only while an agent acts: it starts on the first action and stops after 10 seconds without actions (pauses are cut from the video). H.264 MP4, 8 frames per second, up to 1440p. Hidden apps and Dyrra's own windows are not in the picture.

GET /v1/targets/{target}/recordings
→ 200 { "data": [ { "id": "rec_…", "target": "con_…", "status": "ready",
                    "url": "https://…signed…", "expiresAt": "…",
                    "deletesAt": "…", "durationSec": 42, "createdAt": "…" } ] }

GET /v1/recordings/{recordingId}
→ 200 { "id": "rec_…", "status": "processing" | "ready", "url"?: "…", … }

Record and replay

Record what an agent does once and run it again later without the model — a setup repeated on every new Mac, a check after a change. Recording happens on your side, in the SDK or the MCP server: Dyrra's servers never store a script, so typed text and commands stay with you.

A script is JSON ("format": "dyrra-replay", version 1) with steps of five kinds: actions, open_app, open_url, shell and clipboard_set, each with atMs — milliseconds since the recording started. Screenshots and screen trees are not steps. Clicks on elements are saved as the element — its role plus identifier, title or description — because snapshot ids live 30 seconds; on replay the element is found again in a fresh tree of the front app. A match without any label finds nothing: replay never guesses which of several buttons was meant.

Replay calls the ordinary endpoints, so every rule of a live session applies: pause, availability, hidden apps and the owner's confirmations. When the screen differs from the recording, replay stops at that step with element_not_found and reports which steps ran. Pauses between steps are the recorded ones, capped at 1.5 s by default.


Audit log

What happened on your Macs and which API key did it — for your security team or SIEM. Newest first, 50 per page (up to 200 with limit).

GET /v1/audit-events?limit=50&connectionId=con_…&type=shell.run
→ 200 { "data": [ { "id": "req_…", "at": "…", "actor": "platform", "type": "shell.run",
                    "connectionId": "con_…", "machineId": "mch_…",
                    "apiKey": { "id": "key_…", "prefix": "dk_live_4Tz8", "name": "CI" },
                    "details": { "command": "xcodebuild -scheme App" } } ],
        "nextBefore": "req_…" }

Pass before=<nextBefore> for the next page. Recorded: actions (count and types), terminal commands (the command, up to 200 characters), clipboard reads and writes (length only), opened apps, opened links (site only), file uploads and downloads (path), connections, API keys, cloud leases and payments. Never recorded: typed text, command output, clipboard and file contents, full URLs, screenshots.


Errors

{ "error": { "code": "machine_offline", "message": "…", "request_id": "req_…" } }
HTTP Code When
400 invalid_request Validation failed, coordinates outside the screen
401 unauthorized The key is wrong or revoked
402 payment_required The package limit is reached, or payment is not set up
403 forbidden Another account's resource, or a revoked connection
403 scope_denied The connection lacks the permission; details.reason: "not_granted_on_mac" — permissions were reset on the Mac, the owner has to reconnect it
403 denied_by_user The person denied the confirmation
403 recording_not_shared The owner has not allowed sharing recordings with your platform
404 not_found
408 confirmation_timeout The person did not answer in time
409 machine_offline The app is not connected
409 machine_unavailable details.reason: asleep, screen_locked, no_gui_session
409 machine_paused The person paused the agent
409 machine_busy Another agent holds the turn; details: holder, position. Repeat — the place in line is kept
409 stale_view Another agent used the computer since your last look: take a new screenshot or tree first
409 agent_paused A person paused this agent in the console
409 permission_missing details.permission: screenRecording or accessibility
409 hidden_app_active The action involves a hidden app
409 app_not_frontmost Another app is in front: the input was not sent
409 dyrra_ui_protected Dyrra's own window: no screenshot, no tree, no click
409 lease_not_active The cloud Mac is not leased or the lease has ended
409 machine_already_linked_to_other_user The Mac is already linked to another user of the platform
410 snapshot_expired The ui_tree snapshot has expired
410 link_session_expired The link session has expired
426 app_update_required The app version is too old
429 rate_limited See Retry-After
503 recordings_unavailable Recording storage is not set up on the server
504 timeout
500 internal_error

202 — the command was queued, or a cloud Mac request was received.


Webhooks

Signature: Dyrra-Signature: t=<ts>,v1=<hmac_sha256(secret, ts + "." + body)>. Verify it before parsing the body: dyrra.webhooks.verify(header, rawBody, secret).

Events: connection.created, connection.revoked, machine.online, machine.offline, machine.paused, machine.resumed, machine.permissions_changed, cloud_mac.ready, cloud_mac.expiring, cloud_mac.ended, billing.payment_failed, command.completed, command.failed, command.expired, recording.ready.

Retries with exponential backoff for up to 24 hours.


Limits


What the API does not do