# Agents: the CLI and MCP

An agent can do everything in Flaghand a person can, except approve. Every API operation is a command in the
`flaghand` CLI and a tool in the Flaghand MCP server. Both are generated from the
[OpenAPI spec](https://app.flaghand.com/api/v1/openapi.json), so a new route shows up in both without new code. Neither can approve,
schedule or post: they send API tokens, and the server refuses tokens on those routes.

The loop: connect, then post the plan in one call (`createPost`: it drafts, checks and requests approval), wait for the
owner, then read the live check. The same steps one at a time are in step 4.

## 1. Get a token

Claude, ChatGPT and other apps that add Flaghand as a connector sign in instead, and skip this step: see
[sign in from an app](#mcp-from-claude-chatgpt-and-other-apps-sign-in).

A workspace owner signs in with their passkey, reads `GET /api/v1/me` for the workspace id, then creates an API token
with `POST /api/v1/workspaces/{workspace}/tokens` from the signed-in app (session cookie and the app's own `Origin`):

```json
{ "name": "My drafting agent", "scopes": ["read", "drafts", "media", "request-approval"] }
```

The answer's `token` (`tok_…`) is shown once. The owner gives that value to the agent through its secret environment or
`login --token-stdin`, not in a draft, approval note or shared log. These four scopes are enough for this loop; add
`cancel` only if it may cancel jobs. A token cannot create another token. See [sign-in and tokens](sign-in-and-tokens).

## 2. Connect

### MCP from Claude, ChatGPT and other apps: sign in

Add `https://app.flaghand.com/mcp` as a connector, then sign in when the app asks. Nothing to copy.

- Claude (claude.ai, Claude Desktop, Cowork, the mobile apps): Customize › Connectors › Add › Add custom connector,
  name it Flaghand, paste the URL, Continue, then Add and Connect. On a Team or Enterprise plan an Owner adds it under
  Organization settings › Connectors, and each member clicks Connect. Added on the web or the desktop, it's on the
  phone too.
- ChatGPT (Business and Enterprise, on the web): Plugins › + › Add custom MCP server, name it Flaghand, paste the URL
  under Connection, choose OAuth, tick the risk notice, then Create as a plugin. Type @ in a chat to use it.
- Claude Code: `claude mcp add --transport http flaghand https://app.flaghand.com/mcp`, then `/mcp` to sign in.

The app opens Flaghand's sign-in page: the workspace's owner signs in with their passkey, picks the workspace and
clicks Allow. The app then holds an ordinary workspace token with the four scopes from step 1, named after the app. It
is listed in the desk's Settings › Agents, where Revoke ends it at once. No scope the app asks for adds to those four,
so it can never approve, schedule or post. Only owners can connect an app.
Until public
sign-up opens, someone with no account yet gets a link to the waitlist there.

For app builders, the server follows the MCP authorization spec (2025-11-25):

- Every request without a token, `initialize` included, and any request with a token that isn't valid, answers
  `401` with
  `WWW-Authenticate: Bearer resource_metadata="https://app.flaghand.com/.well-known/oauth-protected-resource/mcp", scope="read drafts media request-approval"`
  (plus `error="invalid_token"` when a token was sent), so an app finds the sign-in from its first `initialize`.
  The server card, `https://app.flaghand.com/.well-known/mcp/server-card.json`, needs no token and lists every tool.
- Protected resource metadata: `https://app.flaghand.com/.well-known/oauth-protected-resource/mcp` (and without `/mcp`), with
  `resource` `https://app.flaghand.com/mcp` and the authorization server `https://app.flaghand.com`.
- Authorization server metadata: `https://app.flaghand.com/.well-known/oauth-authorization-server`. The app identifies itself with a
  client ID metadata document (an https `client_id`, read at each sign-in) or registers at `https://app.flaghand.com/oauth/register`
  (dynamic client registration; `token_endpoint_auth_method` `none`, `client_secret_post` or `client_secret_basic`).
- `https://app.flaghand.com/oauth/authorize` takes the authorization code grant with PKCE, `S256` only, and `resource`
  `https://app.flaghand.com/mcp`. A request it can't take is shown on the page, not sent back; Allow and Cancel go back to the
  app with `iss`. Redirect URIs are https, an app's own scheme, or http on a loopback
  address (matched on any port). A request gets one code: once it's allowed, its `client_id` and `code_challenge` are
  refused, so make a new verifier for each sign-in (RFC 7636 §4.1).
- `https://app.flaghand.com/oauth/token` trades the code and its `code_verifier` (form-encoded) for the token, once: a code used
  twice revokes the token it made. Tokens don't expire and there are no refresh tokens; after a revoke the next
  request gets the `401` and the app signs in again. `https://app.flaghand.com/oauth/revoke` gives a token up (RFC 7009).

### MCP, remote, with a token

The server is at `https://app.flaghand.com/mcp` (streamable HTTP). Send the token as `Authorization: Bearer tok_…`; the bare token
works too.

Claude Code:

```sh
claude mcp add --transport http flaghand https://app.flaghand.com/mcp --header "Authorization: Bearer tok_…"
```

Codex, in `~/.codex/config.toml`, with the token in the `FLAGHAND_TOKEN` environment variable:

```toml
[mcp_servers.flaghand]
url = "https://app.flaghand.com/mcp"
bearer_token_env_var = "FLAGHAND_TOKEN"
```

Any other client: the same URL and header. Every request needs the token, `initialize` included (`401` with
`WWW-Authenticate` otherwise). A description of the server and its tools, readable without a token, is at
`https://app.flaghand.com/.well-known/mcp/server-card.json`.

The tools are named after the spec's operation ids (`getMe`, `createPost`, `createDraft`, `checkDraft`, …), with
the spec's summaries, auth and schemas. `workspace` is optional everywhere: it defaults to the token's workspace. The
connect check is `getMe`. On connecting, the server's instructions give the agent this loop.

### MCP, local

`flaghand mcp` is the same server over stdio. It can also upload files from disk (`uploadMedia` takes `file`).

```sh
claude mcp add flaghand -e FLAGHAND_URL=https://app.flaghand.com -e FLAGHAND_TOKEN=tok_… -- flaghand mcp
```

### CLI

The CLI needs Node 20 or later. It isn't on npm yet; until it is, use the remote MCP server above.

```sh
flaghand me    # the connect check: the intended workspace and the four scopes from step 1
```

The token comes from `FLAGHAND_TOKEN`, which wins over a saved login, or is piped from your secret store into
`flaghand login --token-stdin` (kept per origin in the macOS Keychain; elsewhere, a file only you can read).
`FLAGHAND_URL` picks another origin, without `/api/v1`: set it before logging in, running commands or starting stdio
MCP. A running stdio MCP keeps the environment it started with.

## 3. Post

One call takes the whole plan from text to the owner's approval link: `createPost` in MCP, `flaghand posts create` in
the CLI, `POST /api/v1/workspaces/{workspace}/posts` over HTTP. It needs the `drafts` and `request-approval` scopes.
Each item in `drafts` is a draft as `createDraft` takes it (`kind`, `body`, `connection_id`, and `post_at` or `title`
if it has them), plus the `mode` you'll ask for. It creates every draft, checks each one, and when every check passes
asks the owner to approve them all as one plan, with your `note`. It never approves, schedules or posts.

First find the account each post goes out as: `listConnections` (`flaghand connections list --json`), and take the `id`
of the **active** connection for the platform (`con_X` below). Photos and videos are uploaded first (step 4,
Draft); put their `media_id` in the body.

With MCP, `createPost`:

```json
{
  "drafts": [
    { "kind": "x_post", "connection_id": "con_X", "mode": "post_now", "body": { "text": "We shipped weekly charts today." } }
  ],
  "note": "One X post about this week's launch, posted by the connected account once you approve; no hand steps.",
  "idempotency_key": "6f1d0b8e-3c1a-4c55-9f7e-2a1d5e0c9b42"
}
```

With the CLI:

```sh
flaghand posts create --idempotency-key "$(uuidgen)" --note "One X post about this week's launch." \
  --drafts '[{"kind":"x_post","connection_id":"con_X","mode":"post_now","body":{"text":"We shipped weekly charts today."}}]'
```

```
Approval requested for 1 draft. The owner approves with their passkey here:
https://app.flaghand.com/approve?workspace=ws_…&drafts=drf_…
```

Give the person that `approval_url`, not a separate approval mechanism. They approve there with Face ID or Touch ID.
The answer also has `drafts` (each draft as `getDraft` shows it, with its `id` and `rev`), `checks` (each draft's
check, as `checkDraft` answers it), `items` and `note` (the request, as `requestApproval` answers it), and `message`.
`ok` is true when approval was requested.

`mode` is what the owner is asked to approve: `post_now` (post as soon as it's approved), `schedule` (post at its
`post_at`) or `approve` (the decision only; nothing is posted). Left out, it's what the owner's approval page does
with the draft: `post_now` with no `post_at`, so it goes out once they approve, `schedule` with one, and `approve` when
they post it by hand. A thread is **one** `x_thread` draft with its `posts` in order. Draft kinds and
bodies are in [drafts and media](drafts-and-media).

### When a check finds something

Then `ok` is false, `approval_url` is null and nothing is requested, but the drafts are saved. Each check lists its
problems, each with fixes:

```
Approval wasn't requested: 1 of 1 draft has problems (see checks). The drafts are saved: apply the fixes and send them again, each with its draft_id and rev.
drf_…  rev 1  1 problem:
- X only lets apps quote your own posts, not @someone's. Quote it yourself in X, or post it without the quote.
    fix hand-all: {"by_hand":"all"}
    fix drop-quote: {"quote_url":null}
```

Merge a fix's `patch` into the draft's body, then call `createPost` again with each draft by its id: `draft_id`, the
`rev` from the answer, and only what changes (the complete new `body`, or `post_at` when a problem asks for a time).
A draft that was fine goes back with just `draft_id` and `rev`. Nothing is created twice.

```json
{
  "drafts": [{ "draft_id": "drf_…", "rev": 1, "body": { "text": "We shipped weekly charts today.", "quote_url": null } }],
  "note": "One X post about this week's launch, without the quote.",
  "idempotency_key": "0c2b7f54-8d3e-4b1a-a6f9-5e4d3c2b1a09"
}
```

A `connect` fix means the account needs connecting or reconnecting: the owner does that in the desk.

### Retrying safely

Send a new idempotency key (a UUID) with each new request: `idempotency_key` in MCP, `--idempotency-key` in the CLI,
the `Idempotency-Key` header over HTTP. If an answer never arrives (a timeout, a dropped connection), send the same
request with the same key: it answers what the first one did (`Idempotent-Replayed: true`) and makes nothing twice. The
same key with a different request is refused (`idempotency_key_reused`). Without a key, every call makes new drafts.

## 4. Step by step

`createPost` is these calls in one. Use them to take one step at a time: change a draft, check it again, or ask later.

### Draft

Find the accounts it posts as, upload media, then create one X thread and one LinkedIn post. Replace `con_X` and
`con_LINKEDIN` with the `id` of the intended **active** connection for each platform, and `med_…` with the upload's `id`.
`chart.png` must be your own readable file; use its real pixel dimensions (these examples assume 800 by 600). Local file
paths are relative to the CLI or stdio MCP process's working directory; an absolute path avoids ambiguity.

Choose each proposed time in the owner's plan first: `POST_AT` below is an ISO 8601 time with its UTC offset (`Z` or
`±HH:MM`, like `2026-11-02T08:30:00-08:00`), at least 2 minutes and at most 90 days in the future. A time without an
offset is refused, since it would mean a different moment in every timezone. This proposes a time; it does not
schedule anything. Use `--json` when capturing ids and revs programmatically.

```sh
flaghand connections list --json
flaghand media upload chart.png --alt "Weekly views, up 40%" --width 800 --height 600 --json
flaghand drafts create --kind x_thread --connection-id con_X --title "Weekly charts" --post-at "$POST_AT" \
  --body '{"posts":[{"text":"We shipped weekly charts today.","media":[{"media_id":"med_…"}]},{"text":"How it works: compare each week in one view."}]}' --json
flaghand drafts create --kind linkedin_post --connection-id con_LINKEDIN --title "LinkedIn launch" --post-at "$POST_AT" \
  --body '{"text":"We shipped weekly charts today.","media":[{"media_id":"med_…"}]}' --json
```

A thread is **one** `x_thread` draft with its `posts` in order, not separate drafts replying to each other.

With MCP: `listConnections`, then `uploadMedia` (`data_base64` and `content_type`, or local `file`, plus `alt`, `width`
and `height`), then `createDraft` (`kind`, `connection_id`, `body`, `title`, `post_at`). Upload dimensions are strings in
MCP, for example `"width": "800", "height": "600"`; the CLI takes `--width 800 --height 600`. Use digits without `px`.
They are not detected automatically: without them, previews lack the image's dimensions (`0` is refused). Add video
`duration` in seconds. Repair missing metadata with `media update med_… --width 800 --height 600` (`updateMedia` in
MCP).
Draft kinds and bodies are in [drafts and media](drafts-and-media).

A TikTok slideshow (photos with text, drawn by Flaghand) is built slide by slide or from one JSON document, then
rendered:

```sh
flaghand slideshows create --title "Plan your week" --description "Save this for Sunday."
flaghand slideshows add-slide drf_… --media-id med_…
flaghand slideshows add-text drf_… sld_… --text "Plan your week 🗓️" --preset tiktok_white --y 1500
flaghand slideshows render drf_…               # the PNGs' URLs and the content hash the approval covers
```

With MCP: `createSlideshow`, `addSlide`, `addText`, `renderSlideshow`. See [slideshows](slideshows).

Many drafts at once: write a batch file and import it. It checks the whole batch before uploading or creating
anything, numbers the drafts after the workspace's existing ones, and never changes an existing draft or decision.

```sh
flaghand drafts import batch.json --dry-run
flaghand drafts import batch.json
```

```json
{
  "mediaRoots": ["media"],
  "context": { "launch": { "url": "https://x.com/you/status/1850000000000000000" } },
  "drafts": [
    { "title": "Launch thread", "account": "x", "text": "We shipped it.", "postAt": "2026-11-02T16:30:00Z",
      "media": [{ "src": "media/chart.png", "alt": "Weekly views, up 40%" }],
      "thread": [{ "text": "How it works: …" }], "quote": { "context": "launch" } },
    { "title": "LinkedIn note", "account": "linkedin", "text": "We shipped weekly charts at @[Acme].",
      "tags": [{ "name": "Acme", "kind": "organization", "urn": "urn:li:organization:1" }] }
  ]
}
```

`account` is a connection id, an X handle, or `x` / `linkedin` when the workspace has one account there. Media files
must be inside `mediaRoots` (relative to the batch file). `byHand` (`"first"` for X threads, or `"all"`), `replyTo`
and a LinkedIn `card` work as in the desk. `when`, `notes` and `warnings` have no field in the API and are left out.

### Check

```sh
flaghand drafts check drf_… --mode schedule
```

Check with the mode you'll ask for: `schedule` when the draft has a `post_at`, `post_now` to post as soon as it's
approved, `approve` for the decision only. Left out, it's what the owner's approval page will do with the draft:
`post_now` with no `post_at`, `schedule` with one. It lists everything that would stop the draft posting exactly as
previewed, each with fixes:

```
1 problem:
- X only lets apps quote your own posts, not @someone's. Quote it yourself in X, or post it without the quote.
    fix hand-all: {"by_hand":"all"}
    fix drop-quote: {"quote_url":null}
```

A fix's patch is a change to the draft's body. Merge it into the body and send the whole body with the rev you read:

```sh
flaghand drafts get drf_… --json         # the body and rev
flaghand drafts update drf_… --rev 1 --body '{"text":"We shipped it.","by_hand":"all"}'
```

The body above is an `x_post` example. Preserve the complete body for the draft's actual kind: for a thread, keep its
whole `posts` array. Body updates replace the body; they do not merge it for you.

Not every problem has a body patch. If it asks for a time, update the draft's top-level `post_at` instead. Omit `post_at`
from the next check to use that stored time; an explicit null check override would hide even a valid stored time:

```sh
flaghand drafts update drf_… --rev 1 --post-at "$POST_AT"
flaghand drafts check drf_… --mode schedule
```

Read the returned rev after every update. Check each draft again until it says `Ready` (`ok: true` in JSON).
A completed check exits 0 even when `ok: false`: do not treat the CLI exit code as readiness. A `connect` fix means the
account needs connecting or reconnecting: the owner does that in the desk. With MCP: `checkDraft` (`id`, `mode`),
`getDraft`, `updateDraft` (`id`, `rev`, and the complete `body` or top-level `post_at`).

### Request approval

Ask for the whole plan at once: include both draft ids, who posts each part, its proposed time, and anything the owner
must do by hand. In these examples the connected accounts post everything, so there are no hand steps; `by_hand` changes
that (see [drafts and media](drafts-and-media)). Each draft is checked again; if any has problems, nothing is requested
and the error lists them. Requesting approval creates no approval or job and never posts.

```sh
flaghand approval request drf_… drf_… --mode schedule --note "Launch thread and the LinkedIn post."
```

```
Approval requested for 2 drafts. The owner approves with their passkey here:
https://app.flaghand.com/approve?workspace=ws_…&drafts=drf_…,drf_…
```

The CLI sends each draft with its current rev. With MCP: `requestApproval` with `items` (`draft_id`, `rev`, `mode`) and
an optional `note`. For the two drafts above:

```json
{
  "items": [
    { "draft_id": "drf_THREAD", "rev": 2, "mode": "schedule" },
    { "draft_id": "drf_LINKEDIN", "rev": 1, "mode": "schedule" }
  ],
  "note": "X thread and LinkedIn launch at their proposed times, posted by the connected accounts; no hand steps."
}
```

Replace those ids and revs with the drafts' current values. The answer's `approval_url` is the link to the product's
own approval request; give the person that link, not a separate approval mechanism. They approve there with Face ID or
Touch ID. Editing a draft after this takes it out of the request. Agents never approve, schedule, post, start a waiting
job or send a DM.

## 5. Wait for the owner

```sh
flaghand drafts get drf_…                       # approval_requested, then approved with its job
flaghand jobs list --draft-id drf_…             # scheduled, posting, posted, failed
flaghand events list --action approval.created  # every approval, newest first
```

With MCP: `getDraft`, `listJobs`, `listEvents`. Check every minute or so; the owner may take a while.

## 6. Read the live check

Once a job is `posted`, its `result` has the post links (`urls`) and, for X, the live read-back (`check`): the posts as
X shows them, compared with what was approved (text, hidden reply mentions, media, alt text, the reply chain, the
quote). Report both to the person.

```sh
flaghand jobs get job_… --json
flaghand jobs verify job_…     # read an X post back again
```

## What fails on purpose

`approvals start`, `approvals approve`, `jobs check-first-post`, `connections authorize`, `tokens create` and the other
owner-only operations answer `owner_session_required` for any token. The CLI prints the API's code and its sentence,
and exits 1:

```
error: owner_session_required: Only a workspace owner, signed in with a passkey, can do this. API tokens can't.
```

MCP tools return the same as an error result (`isError`, with `{ error: { code, message } }`). Every code is in
[errors](errors).

## CLI reference

- `flaghand <resource> <action> [ids…] [--field value…] [-d JSON|@file|-] [--json]`. Resources answer to the plural or
  the singular (`drafts`, `draft`), and an operation id works as the command (`flaghand createDraft …`).
- Ids in the path are arguments, in order. Every query parameter and request body field is a `--flag` (`--post-at` or
  `--post_at`); objects and arrays take JSON, and lists also take commas. `-d` sends a whole body.
- `media upload <file>` reads the file; `media get-content <id> --out FILE` saves one.
- `--workspace ws_…` (or `FLAGHAND_WORKSPACE`) picks the workspace; a token's own is the default.
- `--json` prints the API's JSON, errors included. Text output is short and readable.
- Exit codes: 0 done, 1 the API refused (or the server couldn't be reached), 2 a usage mistake.
- `FLAGHAND_TOKEN` (the token), `FLAGHAND_URL` (another server, default `https://app.flaghand.com`).
- `flaghand help`, `flaghand help drafts`, `flaghand drafts create --help`.
