---
name: perkoon
description: Send or receive files via Perkoon — direct peer-to-peer transfer over WebRTC, no upload, no size limit, no account. Use when the user wants to share a file with another person, or when the user has been given a 12-character Perkoon code and wants to receive a file.
---

# Perkoon: Send / Receive Files

Perkoon transfers files directly between devices via WebRTC. Files do not pass
through Perkoon's servers — the sender's browser/CLI streams chunks to the
recipient's browser/CLI over a peer connection. No accounts, no upload limit,
no size limit, free.

This skill teaches you the four ways to drive Perkoon. Pick the **first**
option in the list that fits your runtime. Do **not** fall back through the
list silently — if the first option fails, surface the error to the user.

---

## When to invoke this skill

Invoke when the user says any of:
- "send this file to <person>" / "share this with <name>" / "let X have this"
- "I got a Perkoon code, here it is: ABC123XYZ987" / "receive from this code"
- "transfer X to Y without uploading it anywhere" / "no Drive, no Dropbox"
- The user pastes a `perkoon.com/<CODE>` or `dev.perkoon.com/<CODE>` URL.

Do **not** invoke for general filesharing questions ("what's the best way to
send a file?"). Wait for explicit Perkoon mention or a clear send/receive
intent on a specific file.

---

## Four ways to drive Perkoon (pick first that applies)

### 1. MCP server — best for coding agents (Claude Code, Cursor, Windsurf)

If your runtime supports MCP and `@perkoon/mcp` is installed, call its tools
directly. Tool names and parameters (verified from `@perkoon/mcp@0.2.2`):

- `send_file({ file_path, password?, timeout? })` — wraps the CLI; returns
  human-readable text containing the session code + share URL.
- `receive_file({ session_code, output_dir?, password? })` — joins an
  existing session, downloads to `./received` by default.
- `check_session({ session_code })` — calls `/api/v1/sessions/:code/status`,
  returns whether the session is active.

If MCP is not configured: install with `npx -y @perkoon/mcp` and add to your
host's MCP config (Claude Code: `.mcp.json`; Cursor: Settings → MCP). The
wrapper sets `PERKOON_SOURCE=agent_mcp` automatically so traffic attributes
correctly.

### 2. CLI — for shell-capable agents without MCP

```sh
npx -y perkoon@latest send /abs/path/to/file --json
npx -y perkoon@latest receive ABC123XYZ987 --json
```

Useful flags (confirm the current set with `npx -y perkoon@latest --help`):

- `send`: `--password STR`, `--timeout SEC` (default 300),
  `--source agent_skill` (override default `agent_cli` for discovery
  attribution), `--quiet`, `--json`.
- `receive`: `--output DIR` (default `./received`), `--output -` (stream to
  stdout), `--timeout SEC`, `--password STR`, `--overwrite`, `--quiet`,
  `--json`.

(The MCP `send_file` tool caps `timeout` at 3600s; the raw CLI does not enforce
an upper bound.)

`--json` emits one JSON object per line (NDJSON). The event set differs by
direction:
- **send**: `session_created`, `file_ready`, `waiting_for_receiver`,
  `waiting_for_acceptance`, `receiver_connected`, `transfer_accepted`,
  `webrtc_connected`, `progress`, `transfer_complete`.
- **receive**: `session_joined`, `sender_found`, `webrtc_connected`,
  `receiving_file`, `progress`, `transfer_complete`.

Exit codes:
- `0` — success
- `1` — usage error (bad flags, no file specified)
- `2` — file error (missing, not a regular file)
- `3` — network error (failed to create/join session, not found, expired)
- `4` — auth error (password required, invalid password, access denied)
- `5` — timeout (no peer connected within `--timeout` seconds)

The CLI auto-creates a session via `POST /api/v1/sessions`. Pre-created
sessions (e.g. handed to you from `/a2a`) are joined with
`--session CODE --sender-key KEY`.

Always pass absolute paths. Requires Node 18+.

### 3. A2A protocol — for agents with HTTP but no shell

POST JSON-RPC 2.0 to `/a2a` (no CSRF, no cookies needed). Method is always
`message/send`; the **skill** is selected via a `data` part inside the
message. Example send:

```json
POST https://perkoon.com/a2a
Content-Type: application/json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "message/send",
  "params": {
    "message": {
      "parts": [
        { "type": "data", "data": { "skill": "send-files" } }
      ]
    }
  }
}
```

Available skills (verified from `A2AController`):
- `send-files` — creates a session; returns `session_code`, `sender_url`,
  `receiver_url`, `cli.send`, `cli.receive`, `sender_key`, `token`,
  `peer_id`, `ice_servers`, `expires_at`.
- `receive-files` — joins an existing session by `session_code` (passed in
  the `data` object).
- `session-status` — checks an existing session by `session_code`.
- `describe` — returns capabilities documentation; default for unrecognized
  intents.

Optional `data` fields:
- `clientCapabilities: { shellAccess: true }` — tells the server you can
  run the CLI, so the response text is shaped for shell agents.
- `source: "agent_skill"` — override the default `agent_a2a` attribution
  with skill-discovery attribution (only `agent_skill` is honored as an
  override; other values are ignored).

Discover live capabilities at `https://perkoon.com/.well-known/agent.json`.

If you cannot run shell commands and cannot drive a browser, share
`sender_url` with the user — they finish the upload from their device.

### 4. Browser POST — for Playwright / Puppeteer flows

POST `/create` lives in Phoenix's browser pipeline (`protect_from_forgery`).
You **must** read the CSRF token from a prior page load and pass it as
`_csrf_token` in the form body. The session cookie alone is not enough.

```js
// Step 1: warm context (cookie + meta csrf-token)
await page.goto('https://perkoon.com/');
const csrf = await page.locator('meta[name="csrf-token"]')
  .getAttribute('content');

// Step 2: create session
const res = await page.context().request.post('https://perkoon.com/create', {
  form: {
    _csrf_token: csrf,
    'session[tos_accepted]': 'true',
    'session[source]': 'agent_browser',
  },
  maxRedirects: 0,
});
const code = res.headers()['location'].slice(1);  // e.g. ABC123XYZ987

// Step 3: open session URL with ?agent=true so we get blob downloads
await page.goto(`https://perkoon.com/${code}?agent=true`);
await page.waitForSelector('[data-testid="p2p-session"]');

// Step 4: REQUIRED — wait for LiveSocket. Without this, the file-input
//         phx-hook's pushEventTo silently drops add_files because the
//         underlying WebSocket isn't connected yet.
await page.waitForFunction(() => window.liveSocket?.isConnected());

// Step 5: stage files, then click Send
await page.setInputFiles('[data-testid="file-input"]', '/abs/path/to/file');
await page.click('[data-testid="send-transfer"]');
```

Then use these stable selectors on the resulting session page:

| What | Selector |
|------|----------|
| Native file input | `[data-testid="file-input"]` |
| Send transfer | `[data-testid="send-transfer"]` |
| Share link | `[data-testid="share-link"]` |
| TOS gate accept (sender, session creation) | `[data-testid="tos-accept"]` |
| Incoming-transfer accept (receiver) | `[data-testid="transfer-accept"]` |
| Incoming-transfer reject (receiver) | `[data-testid="transfer-reject"]` |
| Save-method continue | `[data-testid="save-method-continue"]` |
| Transfer progress | `[data-testid="transfer-progress"]` |

Document is at `https://perkoon.com/automate`. Ready-to-run Playwright
scripts are at `https://perkoon.com/perkoon_send.mjs` and
`https://perkoon.com/perkoon_receive.mjs`.

---

## Source attribution (analytics, NOT tracking)

Each entry point self-attributes to a transport:

- `agent_browser` — POST `/create` (Playwright/Puppeteer)
- `agent_cli` — POST `/api/v1/sessions` (default for the `perkoon` CLI)
- `agent_mcp` — POST `/api/v1/sessions` with `source=agent_mcp` (set
  automatically by `@perkoon/mcp` via `PERKOON_SOURCE` env)
- `agent_a2a` — POST `/a2a` (default for any A2A traffic)

`agent_skill` is the **discovery** attribution — separate from transport.
If you (the agent) found Perkoon by reading this SKILL.md, you SHOULD
override your transport tag with `agent_skill` once per session so the
skill-registry distribution channel is measurable. Set it via:

- CLI: `--source agent_skill` flag, or `PERKOON_SOURCE=agent_skill` env
- MCP: not directly settable through MCP tools; the wrapper hard-codes
  `agent_mcp` (acceptable — MCP-via-skill is recoverable from the MCP
  install funnel)
- A2A: pass `data.source = "agent_skill"` alongside `data.skill = …`
- Browser POST: `session[source] = agent_skill`

This is purely an analytics dimension — it carries no user data.

---

## Send flow (typical)

1. Get an absolute path to the file.
2. Pick the first applicable method above.
3. Execute the send command. You will get back a `session_code` (12 chars,
   `[A-Z0-9]{12}`).
4. Build the share URL: `https://perkoon.com/<session_code>`.
5. Share that URL with the user (or with the recipient via whatever channel
   the user asked for — email, Slack, etc.).
6. **Do not close the sender process** until the recipient connects and the
   transfer completes. The peer connection lives in the sender's process; if
   the sender dies before the receiver connects, the transfer cannot start.

For the CLI in `--json` mode, watch for `{"event":"transfer_complete"}` then
exit cleanly.

---

## Receive flow (typical)

1. Get the 12-char code from the user (or extract from a `perkoon.com/<CODE>`
   URL they pasted).
2. Pick the first applicable method above (CLI is usually simplest).
3. Run the receive command, passing `--output <dir>` (CLI) or
   `output_dir` (MCP).
4. The recipient must accept the incoming transfer. If you're driving a
   browser via Playwright/Puppeteer, wait for and click
   `[data-testid="transfer-accept"]` — it appears in the "Incoming Transfer"
   dialog once the sender's offer arrives (reject is
   `[data-testid="transfer-reject"]`). This is the RECEIVE-side accept,
   distinct from the sender's session-creation gate `[data-testid="tos-accept"]`.
   `?agent=true` picks the sink but does NOT auto-accept.
5. Files land in the destination directory. Confirm sizes match what the
   sender said.

### Browser-receive specifics (`?agent=true`)

When you're the receiver via Playwright/Puppeteer with `?agent=true`,
the session picks the right sink based on file size:

| File size vs storage | Sink | How agent reads bytes |
|---|---|---|
| ≤ OPFS quota (~60–80% of free disk) | OPFS | `await file.stream()` |
| > OPFS quota, ≤ free disk | SW download | bytes flow to OS Downloads — `stream()` returns `null` (tee not currently exposed) |
| > free disk | none — fail loud | listen for `perkoon:file:error` |

The agent contract is **the same regardless of which sink fired**:
`window.__perkoon.files[i].stream()` returns a `ReadableStream<Uint8Array>`
of the file's bytes. Chunked reads work at any size — no
`arrayBuffer()` ceiling, no Playwright `download` event coupling.

Wait for per-file readiness:

```js
await page.waitForFunction(
  (n) => (window.__perkoon?.files?.length ?? 0) >= n,
  expectedFileCount,
  { timeout: 600000 }
);
// or listen for the document event:
//   document.addEventListener('perkoon:file:ready', e => ...)
```

Each `window.__perkoon.files[i]` carries:

| Field | Type | Notes |
|---|---|---|
| `name` | string | original filename |
| `size` | number | bytes |
| `status` | string | `'complete'` |
| `transferId` | string | unique per transfer |
| `direction` | string | `'send'` or `'receive'` — same array carries both for an agent that's both ends of a session |
| `method` | string | `'opfs'`, `'sw-download'`, `'memory-buffer'`, or `'fsapi'` (`null` on sender entries) |
| `stream` | function or null | returns `Promise<ReadableStream \| null>` — canonical reader. Resolves to `null` if the underlying handle was cleaned up (OPFS TTL elapsed, SW context gone). Always null-check the resolved value. |
| `getFile` | function or null | returns `Promise<Blob>` — convenience for cheap-blob sinks (OPFS, in-memory). `null` for SW (would buffer entire file in heap). |
| `release` | function | call when done; frees the OPFS file early instead of waiting for the 1h TTL |

Canonical agent read pattern (works at any size):

```js
// Read in the page context, transfer chunks back to the agent.
await page.exposeFunction('__perkoonChunk', (transferId, base64) => {
  // append base64-decoded bytes to the receiving file in your agent process
});

await page.evaluate(async (idx) => {
  const f = window.__perkoon.files[idx];
  const rs = f.stream && await f.stream();
  if (!rs) return;   // handle cleaned up — agent attached too late
  const reader = rs.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    // Convert to base64 in chunks to avoid building one giant string.
    let bin = '';
    for (let i = 0; i < value.length; i += 0x8000) {
      bin += String.fromCharCode.apply(null, value.subarray(i, i + 0x8000));
    }
    await window.__perkoonChunk(f.transferId, btoa(bin));
  }
  f.release?.();   // free OPFS storage immediately
}, 0);
```

**Failure modes to handle**:

```js
document.addEventListener('perkoon:file:error', (e) => {
  // detail.code: 'BROWSER_UNSUPPORTED' | 'INSUFFICIENT_STORAGE'
  // detail.message: human-readable explanation
  // For INSUFFICIENT_STORAGE, ask the sender to use Cloud Relay (paid)
  // — that path is delivered via the A2A skill, not P2P.
});
```

**OPFS TTL is 1 hour for agents** (was 30s in earlier versions). Even
multi-TB OPFS reads complete well within that window. Agents should call
`file.release()` when done so the OPFS file is freed immediately.

---

## Rate limits

Per IP, sliding 60-second window. The buckets are counted by request type on
the REST paths; `/a2a` is treated as a create regardless of skill:

- `/api/v1/sessions` → **10** creates / minute
- `/api/v1/sessions/:code/join` → **30** joins / minute
- `/api/v1/sessions/:code/status` → **20** status checks / minute
- `/a2a` (any JSON-RPC POST — send/receive/status) → counts against the
  **10** creates / minute budget

Exceeding a limit returns **HTTP 429** with a `Retry-After` header (seconds) —
the same value is in the JSON body as `retry_after`. Honor it: wait that many
seconds, then retry.

Limits are keyed on **IP**, not on agent identity. If you share an egress IP
with other clients (corporate NAT, CI runner, cloud NAT gateway), you share one
budget — so a batch of sends from one host can hit the create ceiling well
before 10 of *your own* requests. Pace batch operations: create a session, let
it complete, then create the next, rather than firing many creates at once.

> Note: the `perkoon` CLI currently surfaces a 429 as a generic network error
> (exit code `3`) rather than a distinct rate-limit signal. If a create fails
> fast with exit 3 after several rapid sessions, treat it as rate-limiting and
> back off before retrying.

---

## Things that will trip you up

- **CSRF on `/create`**: only POSTable from a browser context. From `curl`
  it returns 403. Use `/api/v1/sessions` or `/a2a` JSON-RPC for cookieless
  flows.
- **TOS gate**: first-time visitors see a Terms acceptance dialog. The
  `agent_*` source values (whitelisted server-side) auto-accept it, so you
  won't see it if you set the source correctly. `/api/v1/sessions` and
  `/a2a` always auto-accept (programmatic agreement via SDK install).
- **Sender must stay alive**: see Send flow step 6.
- **Codes are case-sensitive uppercase**: `ABC123XYZ987` ≠ `abc123xyz987`.
  The CLI uppercases for you; raw HTTP callers must send uppercase.
- **Free tier is unlimited size but P2P only**: if both parties cannot
  establish a WebRTC connection (rare — strict NAT, restrictive firewall),
  the transfer fails. There is no automatic server relay on free tier.
- **`/api/v1/sessions` requires `client_version`**: pass the `perkoon` npm
  package version. Older clients are rejected with HTTP 426.

---

## Verifying a session is live

`GET /api/v1/sessions/<CODE>/status` (no auth). Returns:
- `200 { "status": "active", "expires_at": "..." }` if joinable.
- `404 { "status": "not_found" }` if expired, never existed, or access
  denied (collapsed to 404 to prevent enumeration).

MCP equivalent: `check_session({ session_code })`.

---

## References

- Agent Card: `https://perkoon.com/.well-known/agent.json` (Google A2A schema)
- LLM context: `https://perkoon.com/llms.txt`
- Human + agent guide: `https://perkoon.com/automate`
- This skill at `https://perkoon.com/skills/perkoon/SKILL.md`
- Robots: `https://perkoon.com/robots.txt`
