# FakeParrot API

---

**Last updated:** October 1, 2026

---

The FakeParrot API is the REST API behind the FakeParrot web app, the Mac app,
the `fakeparrot` CLI and the MCP server. Your code calls the same routes the
web app does. Every request acts as the person who created the token, so it
sees and changes only what that person can.

## Base URL and reference

- **Base URL:** `https://fakeparrot.com`. API routes start with `/api/`, for
  example `https://fakeparrot.com/api/wiki/search`.
- **OpenAPI document:** [`https://fakeparrot.com/openapi.json`](https://fakeparrot.com/openapi.json).
  It lists registered routes and request schemas. Some success schemas are empty
  or generic, so it does not yet describe every response. It also includes
  internal and operator routes; inclusion does not grant token admission.
  Fetch it without signing in. For a small contract, use the discovery flow below.
- Requests and responses are JSON unless a route says otherwise, for example a
  file upload or download.

Not built yet:

- **Version numbers.** Paths have no version, such as `/v1`. The OpenAPI
  document always describes the routes that are live now.
- **An interactive reference viewer.** Machine-readable operation contracts are
  hosted below; open them in an OpenAPI viewer if needed.
- **Client libraries.** There is no official SDK. Use any HTTP client.

## Find one operation

```sh
fakeparrot describe "custom-schedule"
```

`describe` searches `GET /api/developer/operations?q=custom-schedule` using the
caller's existing sign-in. It returns operation IDs, methods, paths, required
scopes, known live prerequisites, `token_admitted`, `unavailable_reason`,
`missing_scopes`, `next_action` and a `contract_url`. A single match also includes
its complete `contract`. Native CLI updates ship separately; if your installed
reply lacks `token_admitted`, use `fakeparrot call GET /api/developer/operations
--query q=custom-schedule < /dev/null`. This uses the same existing sign-in.
For several matches, fetch just the chosen contract:

```sh
fakeparrot call GET /api/developer/contracts/OPERATION_ID
```

MCP `discover_operations` takes a `query` and returns the same discovery facts.
Without sign-in, `GET /api/developer/contracts?q=custom-schedule` supplies the
public index. Each operation contract contains its request, declared responses,
errors, authentication and all referenced schemas. The full OpenAPI file remains
available as an optional bundle.

`token_admitted` describes route admission and known live prerequisites. A route
can still refuse a particular page, workflow or other target. Discovery reveals
no resource names. `route_specific` authentication means a route uses its own
credentials or checks; inspect its contract before calling it. Operator requirements
still need live grants, and a scope does not confer an operator grant.

Retain `schema_revision`; reload the chosen contract when it changes. Accept
additional response fields. Some nested extension sections and uncovered operations
still have generic schemas; do not infer guarantees beyond their declared fields.
Deprecated metadata and explicit `410` responses describe retired operations.
Paths do not promise a versioned compatibility window.

## Sign in

Send a token on every request:

```
Authorization: Bearer fkp_...
```

There are two ways to get one. Both give an `fkp_` token that works the same
way on the API and on the [MCP server](/developers/mcp).

| Way | Best for | How long it works |
|---|---|---|
| **API key** | Your own scripts and servers | Until you revoke it |
| **OAuth 2.1 with PKCE** | An app that other people sign in to | Until 30 days pass with no use, or it is revoked |

### API key

1. In FakeParrot, open **Connections → Agents → Signed in**.
2. Choose **Create API key**, name it, pick its permissions, and copy it.
   FakeParrot shows the key only once.
3. Send it as `Authorization: Bearer fkp_...`

Each person can hold up to 25 live tokens, counting both keys and OAuth
sign-ins. Create one key per app, so you can revoke each on its own. Revoke a key on the same tab. A key cannot create or revoke
keys; only a person signed in to the web app can.

### OAuth

FakeParrot runs a standard OAuth 2.1 server with PKCE and dynamic client
registration. The [MCP server guide](/developers/mcp) has the steps under
**Sign in with OAuth**. The same tokens work on the API.

What to know about OAuth tokens:

- Clients are public: registration returns a client ID (`fpc_...`) and no
  client secret.
- The token response includes `expires_in`, which is 30 days in seconds. The
  30 days restart each time the token is used.
- Ask for `offline_access` to get a refresh token (`fpr_...`). It has no expiry
  date of its own. Each refresh returns a new refresh token; using an old one
  again revokes every token from that sign-in.
- An access token also stops working when the person revokes it or leaves the
  company.
- A refresh token stops working when the person revokes the sign-in, signs out,
  leaves the company, or authorizes the same client again.
- Errors from `POST /api/oauth/token` use the OAuth format,
  `{"error": "invalid_grant", "error_description": "..."}`, not the format
  under **Errors** below.

## Scopes

A token holds one or more of these six scopes. OAuth gives all six when the
client leaves out `scope`.

| Scope | What it allows |
|---|---|
| `wiki:read` | Search, list and read wiki pages and files, compare versions, and read comments. |
| `wiki:write` | Create, edit, move and delete pages and folders, and post comments. Includes `wiki:read`. |
| `workflows:read` | List workflows and read their setup, history and run reports. |
| `workflows:run` | Start a saved workflow. Includes `workflows:read`. |
| `workflows:write` | Create, change, schedule, turn on and turn off workflows. Includes `workflows:read`. |
| `ops:read` | Read workflow and connection health, the team directory, and reports from connected systems. |

`workflows:write` does not include `workflows:run`. Building a workflow and
spending money running it are separate permissions.

**A token with all six scopes** reaches every `/api` route its person can use
in the web app. The route's own checks decide the rest, such as which pages the
person may read. A few routes always refuse a token, and the `403` says why:

- creating or revoking keys, sign-ins and sessions;
- changing who can see something, such as share links, access lists and
  memberships;
- billing changes, and reading billing without the `billing:read` scope;
- accepting legal terms;
- reading or restoring deleted items in the trash.

**A token with fewer scopes** reaches only the route groups its scopes cover,
such as `/api/wiki` for `wiki:read`. It can write only where one of its scopes
allows that write.

`billing:read` is an extra scope that reads the company's billing status. It
is not one of the six, so a token has it only when someone picks it.

## Example request

```bash
curl "https://fakeparrot.com/api/wiki/search?q=expense%20policy" \
  -H "Authorization: Bearer $FAKEPARROT_API_KEY"
```

This needs `wiki:read`. The reply lists the pages the person can read that
match the search.

## Errors

Most API errors use this JSON shape. OAuth errors use the separate format above:

```json
{
  "success": false,
  "error": {
    "code": "ACCESS_DENIED",
    "message": "API token is missing the wiki:write scope",
    "timestamp": "2026-10-01T09:30:00.000000Z",
    "request_id": "…",
    "details": { "error": "insufficient_scope", "scope": "wiki:write" }
  }
}
```

- `code` is a fixed, machine-readable name. Branch on it, not on `message`.
- `message` is written for a person.
- `request_id` matches the `X-Request-Id` header on every response. Include it
  when you contact support.
- `details` appears only when there is more to say.

Common codes:

| Status | `code` | Meaning |
|---|---|---|
| `400` | `VALIDATION_ERROR` | The request did not match the route's schema. `details.validation_errors` lists each problem and `details.field` names the first. Routes under `/api/execution` and `/api/local-file-catalogs` send this with `422`. |
| `400` | `BAD_REQUEST` | The route refused the request. `message` says why. |
| `401` | `AUTH_REQUIRED` | The token is missing, invalid or revoked. |
| `403` | `ACCESS_DENIED` | The person may not do this, or the token cannot reach this route. A missing scope adds `details.error` set to `insufficient_scope`, `details.scope`, and a matching `WWW-Authenticate` header. |
| `404` | `NOT_FOUND` | The route or item was not found. |
| `429` | `RATE_LIMITED` | See **Rate limits**. |
| `500` | `INTERNAL_ERROR` | Something failed on our side. A write may already have happened; reconcile it before retrying. Include `request_id` when contacting support. |
| `503` | `DB_POOL_EXHAUSTED` | The server is briefly at capacity. Honor `Retry-After`. For a write, also follow its retry contract. |

Some routes return their own codes for their own cases. The OpenAPI document
and the `message` describe them.

## Rate limits

There is no blanket request throttle on `/api`, `/mcp` or OAuth. Individual
operations can still have execution limits or capacity responses:

- **Public share links** under `/public/` allow 3,000 requests a minute from
  one IP address, then answer `429`.
- **Sync routes** under `/api/wiki/sync/v2/` may answer `429` with
  `Retry-After` when a server is busy, including calls from CLI or API clients.
- **On-demand workflow starts** have a configurable per-workflow hourly limit.
  The implementation default is 60; this is not a measurement of the deployed
  setting. A refused start can answer `429`.
- **Database capacity** can answer `503` with `Retry-After`.

A workflow that calls a connected system, such as HubSpot or Google, is still
subject to that system's own limits.

## Agent recipes

Examples use representative IDs and response excerpts. Substitute IDs from a
readable result; do not invent them or infer numeric IDs from UUIDs. Authentication
is set up once. CLI examples use its existing sign-in; REST uses your existing token.
An access denial is not a reason to retry under a different identity.

### Read and edit one page

Needs `wiki:read` to search/read, `wiki:write` to edit, and current write access
to the page. On a connected Mac, read a known page from the folder. Search an
unknown page through the server:

```sh
fakeparrot wiki search "expense policy"
fakeparrot wiki get PAGE_UUID
```

For REST, use `GET /api/wiki/search?q=expense%20policy`, then
`GET /api/wiki/pages/PAGE_UUID`. A successful HTTP read can return `access: null`
and only page metadata. Proceed only when the response includes readable content.
Use the returned `page.id` (integer), `page.public_id` (UUID), `page.current_version`
and `page.content_md`; preserve the title and every passage outside the requested change.

MCP `search_wiki` returns `results` with numeric `page_id`, `page_public_id`,
`path`, `title`, `version` and `url`. MCP `read_page` takes the numeric
`page_id` or a supported slug path and returns `page` metadata plus `markdown`.
Use numeric IDs for the MCP tools ending in `_by_id`; their schemas do not accept
public UUIDs. REST page routes accept the public UUID or legacy numeric ID.

For an exact text change, prefer the existing edit operation. Mint one UUID v4,
save it with the request before sending, and reuse both only for an exact retry:

```sh
printf '%s' '{"expected_version":7,"mutation_id":"cf39993a-eccd-4489-9d28-7bdcaa356f30","edits":[{"kind":"replace","find":"30 days","replace":"45 days","all":false}]}' \
  | fakeparrot call POST /api/wiki/pages/PAGE_UUID/edits
```

MCP uses `edit_page_by_id` with the same edit fields and a numeric `page_id`.
The response has `outcome`, `page`, `base_version`, `current_version`, `edits`,
`mutation`, `refusal`, `overtaken`, `attempts`, `max_attempts` and `note`:

| `outcome` | What to do |
|---|---|
| `applied` | One version was written. Read the page again and confirm the intended text and current version. |
| `no_change` | No version was written. Inspect edit counts and read the page; an absent match does not prove the requested text is already present. |
| `conflict` | Another save kept moving the page. Read the current page, re-evaluate the edit and mint a new mutation ID for a revised request. |
| `refused` | Inspect `refusal`; an anchor can be absent or ambiguous, or edits can overlap. Correct the request with a new mutation ID. |
| `overtaken` | Another save removed the target text. Read the current page and decide whether a new edit is still needed. |

Server retries can re-plan an untouched passage over a newer version. Report any
`note` about a concurrent save. Client retries after a lost response keep the original
request and mutation ID; changing the body or observed version requires a new ID.
A reused ID with different content is refused.

For a complete replacement, use `PUT /api/wiki/pages/PAGE_UUID/content` with
`title`, `markdown`, `expected_version` and `mutation_id`, or MCP
`save_page_by_id`. Append through `POST /api/wiki/pages/PAGE_UUID/appends` or
`append_to_page_by_id` with `markdown`, `expected_version` and `mutation_id`.
Inspect the mutation outcome: `newer_version_available` can preserve your content
as `saved_draft_version` without replacing the current head; `stale_noop` does
not write. Read back the current head before reporting completion. Legacy
`save_page` and `append_to_page` do not provide the same caller mutation identity.

### Start and follow one workflow

Needs `workflows:read` to discover/read, `workflows:run` to start, current run
permission and access to the resulting report. `workflows:write` alone does not
allow a run. Some owner-bound workflows execute with their owner's source authority;
the initiating caller and execution actor can differ. Discovery does not grant
run permission or report visibility.

```sh
fakeparrot workflow list
fakeparrot workflow get WORKFLOW_UUID
printf '%s' '{"inputs":{},"run_request_id":"70de57be-8548-40cd-b67d-a737b95224bd"}' \
  | fakeparrot call POST /api/workflows/WORKFLOW_UUID/run
```

Use the workflow's public UUID from the list; load its declared input schema and
supply the required values. REST can test-run a draft and refuses archived/deleted
workflows. MCP `run_workflow` accepts draft or active workflows,
`workflow` (returned ID, display number or exact name), `inputs`, `run_request_id`
and `wait_seconds`.
Use an ID to avoid name ambiguity.

A REST start answers `201`, for example:

```json
{"success":true,"run_id":"96400b64-fb90-4f42-8ca6-b3cd0f2de812","status":"running"}
```

This acknowledges a start. Persist `run_id` before doing anything else; it is also
the report's public ID. Follow it with `GET /api/reports/RUN_UUID` or:

```sh
fakeparrot report get RUN_UUID
fakeparrot report events RUN_UUID
```

The REST report has `schema_version: 2`. Read execution status at
`presentation.execution_status`, and inspect `presentation.outcome`, `states`,
`deliverables` and `delivery_receipts` within `presentation`. The CLI wraps this
body in its command envelope. In a combined workflow run, the report is at
`body.report.body`; its status is `body.report.body.presentation.execution_status`.
MCP `get_workflow_run` takes `run_id` and returns top-level `status`, `outcome`,
`errors`, `stages` and `action_execution` when present. These projections have
different shapes; use their declared schemas rather than assuming identical bodies.

Check execution status and the stored outcome and required deliverables:

| Status | Meaning |
|---|---|
| `queued`, `running` | Work is pending. Continue reading the same run. |
| `awaiting_work_packet` | Waiting for delegated work to return. The report is not complete. |
| `awaiting_approval` | Historical approval pause. Retained as evidence; no approval action is available. |
| `completed` | Execution ended. Verify the actual outcome and required deliverables before reporting success. |
| `failed`, `cancelled`, `stale`, `budget_exceeded` | Execution ended without successful completion. Read errors/events and report the result. |

`fakeparrot workflow run` combines start, polling and events. Its `ok` and exit
code describe command completion, even for a failed or paused run. A wait timeout
ends observation and does not cancel work. Resume reading the same report.

For a retry-safe start, mint a UUID v4 `run_request_id` before sending and persist
it with the exact request. REST, CLI `--run-request-id` and MCP accept it. An exact
retry by the same person in the same company recovers the original run, including
after credential rotation. The key binds the workflow, inputs and title; reuse with
different intent returns `409 WORKFLOW_RUN_REQUEST_CONFLICT`. Omitted inputs and
an empty input object are equivalent. MCP's transport attribution is excluded from
the identity, so the same input request can be recovered through REST or MCP.
Current run authority is checked again; report access is checked separately.
Archiving stops new starts but allows recovery of an existing receipt when the
caller retains authority; a deleted or unreadable target remains unavailable.

Concurrent exact retries reserve one run and increment its call count once. The
receipt commits with the initialized run before dispatch; the existing dispatcher
can recover committed work. Receipts stay with company/run history. After a run is
permanently removed, the key is retained and recovery returns unavailable instead
of starting another run. This deduplicates workflow starts, not external side effects.
To intentionally start new work, mint a new key.

A start without a caller retry identity can create another run if repeated. After
an uncertain response, inspect the workflow's run list and match caller, start time
and inputs; if there is no unique match, report the uncertainty before starting
another paid action. Read calls can be retried; writes need their own retry contract.
Do not treat `500`, a disconnect or a missing response as proof that nothing happened.
### Receive, verify and recover report callbacks

Callbacks send the same JSON shape as `GET /api/reports/{report_id}`, built
with the hook owner's current access. All routes below cost `workflows:read`
for bearer tokens. Registration requires a bearer credential; a browser session
can read, rotate, replay or end its own hooks. Ownership stays with the person
and original credential: an OAuth refresh family survives access-token rotation;
a plain key's revocation ends delivery. A new key does not revive a revoked hook.

| Operation | Request and result |
|---|---|
| `POST /api/reports/{report_id}/callbacks` | `{"url":"https://receiver.example/callback"}` → `201`, one hook for this report; sends at once if already finished. |
| `POST /api/report-hooks` | `target_url`, optional `workflow_public_id` and `statuses` → `201`, a subscription. |
| `GET /api/report-hooks` | Your live hooks, with masked addresses and signing key IDs. |
| `POST /api/report-hooks/{hook_id}/signing-key` | No body → `200`, replace the signing key or enable signing on a legacy hook. |
| `GET /api/report-hooks/{hook_id}/deliveries` | Optional `limit` (default 50, maximum 100) and `offset` → delivery window and `next_offset`. Continue until null; current report access can hide rows in a window. |
| `GET /api/report-hooks/{hook_id}/deliveries/{delivery_id}` | Current delivery state, attempt totals, timestamps and a sanitized failure code. |
| `POST /api/report-hooks/{hook_id}/deliveries/{delivery_id}/replay` | `{"replay_count":0}` → `200`, queues the eligible failed delivery once for that observed generation. |
| `DELETE /api/report-hooks/{hook_id}` | `204`, stops delivery and removes stored signing material. Repeating it succeeds. |

Hook and delivery IDs are UUIDs. Delivery history includes ended hooks when you
retain their IDs. Only the owner in the same company can read it; every report
also needs current read access. Another person's hook or unreadable report
returns `404`, including for admins. History lasts while the hook, owner and run
remain stored; ending a hook does not delete its history.

Registration adds `signing_key_id` and a one-time `signing_secret` to the existing
hook response. Rotation returns `hook_id`, `signing_key_id` and `signing_secret`.
These responses use `Cache-Control: no-store`. Save the secret in your receiver's
secret store; never put it in the callback URL, a log, or source control. It is
32 random bytes represented as 64 hex characters. **Use those characters as the
UTF-8 HMAC key; do not hex-decode them.** FakeParrot stores it encrypted and never
returns it through listings, history, discovery or later reads. If a registration
response is lost, find the hook in your list. If a key response is lost, rotate
again; rotation itself is not idempotent.

Existing hooks remain unsigned until you call `signing-key`. New hooks are
signed by default, retaining the existing JSON and delivery headers. Receivers
that ignore additional headers remain compatible. An already-finished report
can arrive before its registration response: return a retryable response such
as `503` until the receiver has saved its verification key.

#### Verify before parsing or processing

Each signed POST includes:

- `X-FakeParrot-Delivery`: the durable delivery UUID.
- `X-FakeParrot-Hook`: the hook UUID.
- `X-FakeParrot-Key-Id`: the signing key UUID.
- `X-FakeParrot-Timestamp`: Unix seconds for this attempt.
- `X-FakeParrot-Signature`: `v1=` followed by lowercase HMAC-SHA256 hex.

The signed bytes are the UTF-8 prefix
`v1.TIMESTAMP.DELIVERY_UUID.HOOK_UUID.KEY_UUID.` followed by the **raw HTTP body
bytes**. Do not parse and re-serialize JSON before verification. Match the hook
and key ID against your receiver configuration, use a constant-time comparison,
and check timestamp freshness under your receiver's policy. For example:

```python
import hashlib
import hmac
import time

# secret and known IDs come from your receiver's protected configuration.
def verify(headers, raw_body, hook_id, key_id, secret):
    if headers.get("X-FakeParrot-Hook") != hook_id:
        return False
    if headers.get("X-FakeParrot-Key-Id") != key_id:
        return False
    timestamp = headers.get("X-FakeParrot-Timestamp", "")
    try:
        if abs(time.time() - int(timestamp)) > 300:
            return False
    except ValueError:
        return False
    delivery_id = headers.get("X-FakeParrot-Delivery", "")
    prefix = f"v1.{timestamp}.{delivery_id}.{hook_id}.{key_id}.".encode()
    expected = "v1=" + hmac.new(
        secret.encode(), prefix + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(
        expected, headers.get("X-FakeParrot-Signature", "")
    )
```

Rotation replaces the server's signing material immediately. Future attempts
read the current key, including retries and replay. An already-in-flight request
may carry the previous key; keep both receiver keys briefly when handling a
planned rotation, then remove the old one. If compromised, remove the old key
immediately and reject those requests. Ending the hook prevents new sends;
a POST already in flight may still arrive. Missing or unreadable signing material
fails closed with `signing_key_unavailable`; it never downgrades to unsigned.

#### Delivery outcomes and replay

A delivery returns `id`, `hook_id`, `report_id`, `state`, `attempts`,
`replay_count`, `created_at`, `last_attempt_at`, `next_attempt_at`, `delivered_at`
and `failure_reason`. States are `pending`, `delivered`, `failed` or `cancelled`.
`delivered` means the receiver answered `2xx`; it does not prove its downstream
action succeeded. Failure details are codes such as `http_400`, `timeout`,
`blocked` or `request_error`, never receiver response bodies or addresses.
`attempts` counts settled network attempts over the delivery's lifetime; a worker
crash before settlement can leave an uncounted request.

Transport failures, `408`, `425`, `429` and server errors retry automatically:
up to eight attempts per initial delivery or replay, with 30-second backoff
doubling to at most one hour. Redirects are never followed. Other HTTP responses
fail the delivery; `410` also ends the hook. Signing failures are recorded without
a network attempt. A subscription survives an ordinary delivery failure; a
one-report callback ends as undeliverable and can be reopened by replay.

Read a failed delivery, repair the receiver or signing key, then POST its current
`replay_count`. The original row and ID remain; `replay_count` increases by one,
the retry budget restarts, and attempt totals remain. Repeating the **same observed
count** recovers that replay's current outcome, even if it has failed again. To
request a further replay, read and submit the new count. A stale count or an
ineligible state returns `409`. Delivered and cancelled deliveries cannot start
another replay. An unsubscribed hook, a `410` receiver, or an ended credential
cannot be revived. Replay checks the caller's access, the original credential's
live scopes, company membership and report access, and delivery checks them again
before sending. Refresh in progress returns `409`; lost credential authority
returns `403`. A successful replay response means recovery was accepted; read
its delivery state to learn whether the receiver acknowledged it.

Delivery is at least once. `X-FakeParrot-Delivery` stays the same across automatic
retries, lease-expiry recovery and replay. `X-FakeParrot-Hook` and the report ID also
stay the same. The body is rebuilt from current readable report state on every
attempt, so its bytes can change under the same delivery ID. Timestamps and
signatures are recomputed, and the signing key ID changes on rotation.

After verifying, atomically record the delivery ID with your receiver's effect
or durable work item. An authenticated duplicate must receive a successful
acknowledgement without repeating the effect, even if its report bytes differ.
Separate registrations get separate hook and delivery IDs. Keep deduplication
records for as long as you allow replay. FakeParrot does not promise exactly-once
external effects; downstream systems need their own idempotency support.

The CLI reaches all these routes with `fakeparrot call`. MCP exposes
`list_report_hook_deliveries`, `get_report_hook_delivery`,
`replay_report_hook_delivery` and `rotate_report_hook_signing_key` with the same
services, scopes and live authorization. For registration and status filters,
see the [Zapier and Make guide](/developers/zapier-and-make).

### Author a workflow and confirm it was saved

Needs `workflows:write` and the caller's live authoring permissions. Load
`GET /api/workflow-capabilities` or MCP `list_workflow_capabilities`, then reuse an
existing workflow by revising it when the task concerns that workflow.

```sh
fakeparrot describe /api/workflows/blocks/design
printf '%s' '{"job":"Every Monday send me a pipeline summary","start_as":"active","wait":false,"authoring_request_id":"4c88a0ef-cd93-4b71-87a1-3af9e35e2eec"}' \
  | fakeparrot call POST /api/workflows/blocks/design
```

The REST create route defaults to `start_as: "draft"`; choose `active` when the
requested task includes scheduling. Draft is a caller choice, not an extra approval
step. MCP `create_workflow` defaults to active and requires the intended visibility
and delivery destination. Supply the user's actual job; the service composes the
workflow. The REST route takes the same choices as MCP: `visibility` (`company` by
default, `team` with `team`, or `private`), `output.create_page` for a wiki page,
`trigger.schedule`, `model`, `source_requirements`, `report_spec`, `intent` and
`execution_mode`. Nothing is inferred from the job's words: without
`output.create_page` the workflow writes no page. Inspect the current schema for
each field's shape.

A background response returns `operation_id` and `status_url` with
`status: "composing"`. Poll the returned status URL or MCP
`get_workflow_authoring_operation`. `saved` means the definition was saved; it does
not prove a requested run completed. Read the resulting workflow and schedule,
then follow any returned run ID separately. Relay clarification, capability-gap
and failure outcomes instead of claiming a workflow was created.

An exact retry with the same `authoring_request_id` reuses the existing operation
for that caller and company. Different intent under the same ID conflicts. Preserve
the request and receipt across reconnects; revised intent needs a new ID.


## Help

Email [support@fakeparrot.com](mailto:support@fakeparrot.com) with the
`request_id` from the error. Report security issues to
[security@fakeparrot.com](mailto:security@fakeparrot.com).
