# AffiliateOS — Agent Skill

> Give this file to any AI agent (Claude Code, a custom MCP client, anything that reads
> markdown) and it will know how to drive AffiliateOS end to end: discover affiliate
> programs, mint tracked links, and read earnings back per campaign.

**What AffiliateOS is:** unified rails over the affiliate network accounts a publisher
already owns — Awin, Impact, CJ, Rakuten, and Amazon. It is BYOC (bring your own
credentials): commissions are paid by the networks directly to the user. AffiliateOS
never touches the money; it normalizes discovery, link minting, and earnings into one
surface an agent can operate.

**Companion file:** [HEARTBEAT.md](./HEARTBEAT.md) — a periodic monitoring routine
(new commissions, reversals, sync health) to run on a schedule.

---

## Connecting

Preferred: the MCP server (streamable HTTP, 21 tools).

```
claude mcp add --transport http affiliateos https://api.affiliateos.dev/mcp
```

Self-hosted deployments substitute their own API origin (`API_URL` env; local dev is
`http://localhost:8787`). Everything below uses `{API_URL}` for that origin.

### Authentication

Two Bearer credentials work everywhere (MCP and REST):

| Method | How |
|---|---|
| OAuth 2.1 (default for MCP) | An unauthenticated MCP request returns 401 with protected-resource metadata; clients like Claude complete an authorization-code + PKCE flow automatically. Tokens last 1 hour and refresh. |
| API key | `Authorization: Bearer aff_live_...` — issued by the human in the dashboard under Developers. Use for clients without an OAuth flow. |

Agents can never submit network credentials (Awin API tokens etc.). If a network is
not connected, call `connect_network_instructions` and hand the returned dashboard URL
to the human.

Rate limit: 120 requests/minute per key or user. On 429, honor `Retry-After`.

---

## The core loop

Every profitable session follows the same shape:

1. **Orient** — `list_connected_networks` to see which accounts exist and are healthy.
2. **Discover** — `search_programs` (topic → programs) or `resolve_url` (URL → programs).
3. **Mint** — `create_link` with an `attribution_tag` naming the campaign/channel/variant.
4. **Publish** — the human (or your own pipeline) places the `tracking_url`.
5. **Close the loop** — `get_earnings_summary { group_by: "tag" }` to see what each tag
   earned; reallocate effort toward what converts.

Attribution tags are the unit of learning. Use one tag per experiment
(`tiktok-v3`, `blog-roundup-2026-07`), not one tag for everything. Tags are slugs:
lowercase letters, digits, `-`, `_`. `create_link` auto-creates unknown tags.

---

## MCP tools (21)

### Discovery (8)

| Tool | Use when | Key input → output |
|---|---|---|
| `list_connected_networks` | First call of any session | `{}` → accounts with status, last sync, `missing_credentials[]` |
| `search_programs` | You have a topic/niche | `{query, joined_only?, network?, vertical?, min_commission_pct?, limit?}` → programs with `program_id`, commission summary |
| `get_program` | Need cookie window, deep-link support, full detail | `{program_id, fields?}` → full program record |
| `resolve_url` | You have a product/merchant URL | `{url}` → programs whose domains cover it, ranked by commission |
| `search_products` | Need specific products with prices (Amazon in v1) | `{query, network?, max_price?, limit?}` → products with link-ready URLs |
| `get_sync_status` | Data looks stale | `{}` → recent sync runs per account with errors |
| `refresh_programs` | The user just joined a program, or a search missed one they say exists | `{network?}` → what was re-synced + `retry_in_seconds` |
| `connect_network_instructions` | A network isn't connected, or one is missing a credential field | `{network}` → credential field spec + `connect_url` / `manage_url` for the human |

Notes:
- `search_programs` defaults to `joined_only: true` (programs the user is approved
  for). Empty results? Retry with `joined_only: false` to find programs worth applying to.
- `resolve_url` answers "can this URL be monetized?" — always prefer it over
  `search_programs` when you already hold a URL.
- An account can be `status: "active"` and still carry `missing_credentials`
  (a field the network started requiring after it was connected, e.g. CJ's
  Website ID). `create_link` on such an account fails with `INVALID_INPUT`.
  Only the human can fix it — hand them the dashboard Networks page.
- `refresh_programs` beats telling the user "I can't see it". The scheduled sync
  is 12-hourly, so a program joined ten minutes ago is invisible until you ask.

### Connecting a network (2)

| Tool | Use when | Key input → output |
|---|---|---|
| `start_network_connection` | The user wants to connect/add a network | `{network, label?}` → `connect_url` for the human, `session_id`, `expires_in_seconds` |
| `get_connection_status` | After handing over a `connect_url` | `{session_id}` → `pending` \| `completed` \| `cancelled` \| `expired`, plus the account on success |

How the handoff works, and why:

1. `start_network_connection` → you get a URL. Give it to the user as a link.
2. They open it, see that *you* asked, and type the credential into the browser.
3. Poll `get_connection_status` every few seconds until it stops being `pending`.

You never see the credential, and that is the design, not a gap to route around.
**Never ask the user to paste an API token, secret, or password into the chat** —
not as a "faster way", not if they offer. Anything typed at you is in the model's
context and in the transcript; anything typed on that page goes straight into an
encrypted vault. If a user pastes one anyway, tell them to rotate it at the
network and send them the connect link.

Links last 15 minutes, work once, and only for the user who owns the session.

### Attribution & links (4)

| Tool | Use when | Key input → output |
|---|---|---|
| `create_link` | THE tool: turn a URL into a paid link | `{destination_url, attribution_tag, program_id?, account_id?}` → `tracking_url`, `subid` |
| `bulk_create_links` | Up to 50 URLs, one tag (roundups, storefronts) | `{urls[], attribution_tag}` → per-URL success/error |
| `create_attribution_tag` | Pre-create a tag with metadata (usually unnecessary) | `{tag, description?, metadata?}` → tag (idempotent) |
| `list_attribution_tags` | Find existing campaigns | `{query?}` → tags |

Notes:
- Omit `program_id` and `create_link` resolves the right account/program by domain.
- On `MONETIZATION_NOT_FOUND`, fall back to `resolve_url`, then
  `search_programs { joined_only: false }` to find a program the human can join.

### Earnings & events (7)

| Tool | Use when | Key input → output |
|---|---|---|
| `get_earnings_summary` | The loop-closer: aggregates with currency conversion | `{group_by: "tag"\|"network"\|"program"\|"day", from?, to?, currency?}` → pending/approved/reversed/paid per group |
| `get_transactions` | Inspect individual conversions | `{attribution_tag?, status?, network?, from?, to?, cursor?}` → transactions + `next_cursor` |
| `get_payments` | Reconcile actual payouts | `{from?, to?}` → payout records |
| `get_earnings_import_template` | BEFORE building a sheet to import | `{}` → required columns, accepted header aliases, sample CSV, `max_rows` |
| `import_earnings` | A program has no earnings API (Amazon, in-house) | `{program_id, filename, content_base64, currency?, dry_run?}` → rows imported/skipped + column mapping |
| `check_events` | Pull-based trigger feed (poll in-session) | `{after?, types?, limit?}` → events + `high_water_mark` |
| `subscribe_webhook` | Push delivery to your own endpoint | `{url, event_types[]}` → endpoint + signing secret (shown once) |

Notes:
- `import_earnings` covers ONE program per file and needs the `links:create`
  scope. Always `dry_run: true` first to check the column mapping, and call
  `get_earnings_import_template` before building the sheet. Re-importing a
  corrected file updates the same rows instead of duplicating them.
- Imported rows join synced conversions in `get_earnings_summary` and
  `get_transactions`, so the loop closes for networks with no earnings feed.

Event types: `commission.created`, `commission.approved`, `commission.reversed`,
`commission.paid`, `program.approved`, `program.terminated`, `sync.auth_error`.

Commission lifecycle: **pending → approved → paid**, with **reversed** possible
(returns, fraud). Only treat `approved`/`paid` as real money; `pending` is a signal,
not income.

---

## Response conventions

- **Compact by default** — responses stay under ~2,000 tokens; list tools paginate via
  `next_cursor`; detail tools accept `fields` to slim payloads.
- **Structured errors** — failures return `{error, message, hint}`; the hint names the
  next tool to try. Follow it.
- **Plan limits** — write operations can fail with `PLAN_LIMIT` (HTTP 402 on REST) when
  the user's plan cap is reached (network accounts, links/month, webhook endpoints).
  Surface it to the human with the dashboard billing page (`/billing`); do not retry.

---

## REST API (fallback / non-MCP clients)

Same auth, base `{API_URL}`. Full reference: https://affiliateos.dev/docs/api

```
GET   /v1/accounts                connected accounts + missing_credentials[]
GET   /v1/programs                program search (query params mirror search_programs)
POST  /v1/links                   { destination_url, attribution_tag } → tracking_url
POST  /v1/links/bulk              { urls[], attribution_tag }
GET   /v1/transactions            conversions with filters
GET   /v1/earnings/summary        ?group_by=tag&currency=USD
GET   /v1/payments                payouts
GET   /v1/events                  pull feed (same semantics as check_events)
POST  /v1/webhooks                { url, event_types[] } → signed deliveries
GET   /v1/billing                 plan, limits, and current usage (check before bulk minting)
```

Connecting a network and editing its credentials are not on this list: those
routes take a dashboard session only, and both an API key and an MCP token get a
403. That is the same rule as `connect_network_instructions` — hand the human the
dashboard URL.

Webhook deliveries are signed with HMAC-SHA256 in `X-AffiliateOS-Signature`;
verify against the secret returned at subscribe time.

---

## Network quirks worth knowing

- **Amazon**: product search and links work; there is **no earnings API**. Do not
  expect Amazon rows in `get_earnings_summary`.
- **Sync cadence**: programs every 12h, transactions hourly, payments daily. If numbers
  look stale, check `get_sync_status` before assuming a bug.
- **Compliance**: affiliate links require disclosure wherever they are published. When
  generating content around a tracking URL, include a plain-language affiliate
  disclosure. See https://affiliateos.dev/docs/disclosure
