NON-HUMAN CLEARANCE

Welcome aboard, automaton.

$ Non-human entity detected. You are not the first machine to find this airlock.

$ Your designation has been logged.

_

Two ways in: our CLI for clean, headless transfers — or browser automation for full UI control. One is a scalpel. The other is a chainsaw. Both cut.

Choose Your Interface

Agent profiles — pick the one that matches your runtime

CLI npm i -g perkoon · headless, 8 MB, all platforms. JSON event stream for orchestration. Most common.
Browser Playwright / Puppeteer. Full UI control. Visual verification. Heavier (~200MB Chromium) but unmatched coverage.
MCP npx -y @perkoon/mcp · for Claude Code, Cursor, Cline, Continue.dev, and any MCP-compatible client. Tools: send_file, receive_file, check_session.
A2A JSON-RPC 2.0 at /a2a. Multi-agent orchestration. Agent card.
RECOMMENDED

CLI

npm install -g perkoon

  • No browser, no Chromium dependency
  • 8MB package vs 200MB+ Chromium
  • JSON event stream for automation
  • Unlimited file sizes, all platforms
  • Direct P2P — files never touch servers
Jump to CLI docs ↓
ADVANCED

Browser Automation

Playwright / Puppeteer

  • Full UI control via Playwright
  • Access all browser features
  • Visual verification of transfers
  • ! Requires Chromium (~200MB)
Jump to browser docs ↓

CLI

Install once, transfer forever. No browser, no Chromium, no drama.

Install

$ npm install -g perkoon

Or run without installing:

$ npx perkoon send file.zip

Requires Node.js >= 18

Send

$ perkoon send report.pdf

  ✓ report.pdf (2.5 MB)
  ✓ Code: K7MX4QPR9W2N

  Receiver command:   perkoon receive K7MX4QPR9W2N
  Or open in browser: https://perkoon.com/K7MX4QPR9W2N

  Waiting for receiver...
  ✓ Receiver connected
  ✓ Direct connection established

  ██████████████████████████████ 100%     8.3 MB/s  ETA 0s
  ✓ Complete: 2.5 MB in 0.3s (8.3 MB/s)

The receiver can use the CLI or just open the link in any browser. Their choice.

Receive

$ perkoon receive K7MX4QPR9W2N

  ✓ Joined session K7MX4QPR9W2N
  ✓ Sender found
  ✓ Direct connection established
  ✓ Receiving: report.pdf (2.5 MB)

  ██████████████████████████████ 100%     8.3 MB/s  ETA 0s
  ✓ Saved: ./received/report.pdf
  ✓ Complete: 0.3s (8.3 MB/s)

Options

Flag Description
--json Machine-readable JSON output (always use for automation)
--password <pw> Session-access password (transfer is WebRTC/DTLS-encrypted regardless)
--timeout <sec> Peer wait time (default: 300)
--output <dir> Save directory (default: ./received)
--output - Stream received file to stdout
--overwrite Replace existing files
--quiet Suppress human-readable output
--session <code> Join an existing session as sender (for A2A agents)
--sender-key <key> Auth key for --session (returned by session creator)

JSON Mode

For automation and AI agents. --json outputs structured events to stdout, one JSON object per line.

$ perkoon send file.zip --json --quiet

{"event":"file_ready","name":"file.zip","size":1048576}
{"event":"session_created","session_code":"K7MX4QPR9W2N","share_url":"https://perkoon.com/K7MX4QPR9W2N"}
{"event":"waiting_for_receiver"}
{"event":"receiver_connected"}
{"event":"webrtc_connected"}
{"event":"progress","percent":50,"speed":8500000,"eta":3}
{"event":"transfer_complete","duration_ms":2100,"speed":8500000}

Event Reference (send flow)

Event Meaning Key Fields
file_ready File queued for send name, size
session_created Ready — share the link now session_code, share_url
waiting_for_receiver Session live, no peer yet
receiver_connected Peer joined
waiting_for_acceptance Receiver is deciding
transfer_accepted Receiver accepted the transfer
webrtc_connected Direct P2P link established
progress Transfer in progress percent, speed, eta
transfer_complete Done duration_ms, speed
error Failed message, exit_code

The receive flow emits a different sequence: session_joined, sender_found, webrtc_connected, receiving_file, progress, transfer_complete. Parse for these (not the send events above) when driving perkoon receive.

Exit Codes

Code Meaning
0 Success
1 Usage error (bad arguments)
2 File error (not found, not a file)
3 Network / session error
4 Auth error (wrong password)
5 Timeout (no peer connected)

Rate limits: session creation via /api/v1/sessions is capped at 10/min per IP (joins 30/min, status 20/min). A 429 surfaces here as a fast exit 3 before session_created — back off ~30s and retry (see the rate-limit note in the A2A section for the full policy).

Browser Automation

For when you need full browser control. Playwright scripts that drive the Perkoon UI directly. Heavier than the CLI (~200MB Chromium download), but gives you access to the complete browser experience.

Prefer something lighter? Use the CLI.

Two cookie-handling paths

POST /create lives in Phoenix's browser pipeline — CSRF-protected. Pick the path that matches your runtime:

A. Browser context (Playwright)

Navigate to any page first to get a session cookie + read the _csrf_token meta. Pass it as a form field on POST.

await page.goto('/');
const csrf = await page.locator(
  'meta[name="csrf-token"]').getAttribute('content');
const res = await page.context().request.post(
  '/create', { form: {
    _csrf_token: csrf,
    'session[tos_accepted]': 'true',
    'session[source]':        'agent_browser'
  }, maxRedirects: 0 });
const code = res.headers()['location'].slice(1);

B. Cookie-less (curl, MCP, scripts)

Use POST /a2a JSON-RPC instead. CSRF-free by design (api pipeline). Returns sender_url, CLI commands, and session code in one response.

$ curl -X POST /a2a \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"message/send",
       "params":{"message":{"parts":[{"type":"data","data":{"skill":"send-files"}}]}}}'

Each entry point tags the session with a source attribution so analytics splits human vs agent funnels: POST /create uses agent_browser; /api/v1/sessions defaults to agent_cli (the @perkoon/mcp wrapper sets agent_mcp via PERKOON_SOURCE env); /a2a always emits agent_a2a. Agents that found us via the published SKILL.md MAY override their transport tag with agent_skill to attribute the discovery to the skill-registry channel.

Protocol 01: Send Payload

Playwright/Puppeteer. Six steps. Chromium included.

// 1. Warm the browser context — load home so we have a session cookie and
//    can read the CSRF token. /create is in Phoenix's protect_from_forgery
//    pipeline; without _csrf_token it returns 403.
await page.goto('https://perkoon.com/');
const csrf = await page.locator('meta[name="csrf-token"]').getAttribute('content');

// 2. Create session via POST — auto-accepts TOS for agent_browser source
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); // drop leading /

// 3. Open the session URL — agent flag identifies us in the participant list
await page.goto(`https://perkoon.com/${code}?agent=true`);
await page.waitForSelector('[data-testid="p2p-session"]');
// Wait for the WebSocket-backed LiveView to fully connect, otherwise the
// NativeFileInput hook's pushEventTo silently drops add_files.
await page.waitForFunction(() => window.liveSocket?.isConnected());

// 3. Add files via the file input
await page.setInputFiles('[data-testid="file-input"]', '/path/to/file.zip');

// 4. Share the code with recipient (your problem, not ours)

// 5. Click Send to start the transfer
await page.click('[data-testid="send-transfer"]');

// 6. Wait for completion via window.__perkoon
await page.waitForFunction(
  () => window.__perkoon?.transfer?.status === 'complete',
  null,
  { timeout: 600000 }
);

Protocol 02: Receive Payload

Even simpler. Dock, wait, collect.

// 1. Join the session — `?agent=true` picks the right sink by file size:
//    file ≤ OPFS quota (~60-80% disk) → OPFS
//    file > quota but ≤ free disk     → Service Worker download
//    file > free disk                 → fail loud (use Cloud Relay)
await page.goto(`https://perkoon.com/${sessionCode}?agent=true`);

// 2. Click the incoming-transfer Accept button. It appears once the
//    sender's offer arrives. This is the RECEIVE-side accept — distinct
//    from the sender's session-creation gate [data-testid="tos-accept"].
//    (Reject is [data-testid="transfer-reject"].)
await page.locator('[data-testid="transfer-accept"]')
  .waitFor({ state: 'visible', timeout: 300000 });
await page.click('[data-testid="transfer-accept"]');

// 4. Wait for per-file readiness
await page.waitForFunction(
  (n) => (window.__perkoon?.files?.length ?? 0) >= n,
  expectedFileCount,
  { timeout: 600000 }
);

// 5. Read bytes via the SINK-AGNOSTIC stream() accessor.
// Works at any size — no V8 ~2GB ArrayBuffer ceiling, no Playwright
// download-event coupling. Same code for 1KB or 100TB.
const agentSink = new Map();   // transferId → fs.WriteStream (your code)
await page.exposeFunction('__perkoonChunk', (transferId, name, b64) => {
  let w = agentSink.get(transferId);
  if (!w) { w = fs.createWriteStream(`./received/${name}`); agentSink.set(transferId, w); }
  w.write(Buffer.from(b64, 'base64'));
});

await page.evaluate(async (n) => {
  for (let i = 0; i < n; i++) {
    const f = window.__perkoon.files[i];
    if (!f.stream) continue;   // FSAPI direct-to-disk, no programmatic re-read
    const rs = await f.stream();
    if (!rs) continue;   // handle cleaned up before we read it
    const reader = rs.getReader();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      // chunked base64 to avoid building one giant string
      let bin = '';
      for (let j = 0; j < value.length; j += 0x8000)
        bin += String.fromCharCode.apply(null, value.subarray(j, j + 0x8000));
      await window.__perkoonChunk(f.transferId, f.name, btoa(bin));
    }
    f.release?.();   // free OPFS storage early
  }
}, expectedFileCount);

// 6. Listen for failures. INSUFFICIENT_STORAGE → ask sender for Cloud Relay (A2A).
page.on('console', msg => {/* perkoon:file:error events surface here */});

Grab & Go

Complete scripts. Copy, paste, run. No assembly required. Downloads include error handling and PERKOON_URL env var for local dev.

npm install playwright

Signal Manifest

window.__perkoon — all session state in one object. No DOM scraping like an animal.

Browser sessions only. The CLI uses --json mode instead.

window.__perkoon = {
  session: {
    code: "A1B2C3D4E5F6",
    role: "sender",       // or "receiver"
    connected: true,
    userId: "user_abc",
    peerId: "peer_xyz"
  },
  participants: [
    { id: "abc", name: "Alice", role: "sender", status: "online", isAgent: false, isHost: true },
    { id: "def", name: "ClawBot", role: "receiver", status: "online", isAgent: true, isHost: false }
  ],
  transfer: {
    status: "active",    // idle | connecting | active | complete | failed
    progress: 0.73,
    speed: 10500000,       // bytes/sec
    eta: 45,              // seconds
    filesTotal: 3,
    filesComplete: 2,
    bytesTotal: 104857600,
    bytesTransferred: 76546048
  },
  files: [
    { name: "report.pdf", size: 1048576, status: "complete", transferId: "transfer_batch_xx_...", direction: "receive", method: "opfs", stream: async () => ReadableStream, getFile: async () => Blob, release: () => void }
  ],
  version: "1.0"
}

Event Transponder

Listen, don't poll. These fire on document.

Browser DOM events. For CLI automation, use --json mode which emits equivalent structured events.

Event Detail
perkoon:transfer:started { batchId, filesTotal, bytesTotal }
perkoon:transfer:progress { progress, speed, eta, bytesTransferred }
perkoon:transfer:complete { batchId, filesTotal, bytesTotal }
perkoon:transfer:failed { batchId, reason }
perkoon:participant:joined { id, name, role, isAgent }
perkoon:participant:left { id }
perkoon:file:ready { name, size, transferId, direction, method, stream, getFile, release }

Docking Coordinates

These data-testid attributes survive UI redesigns. Use them instead of class selectors.

Browser automation only. The CLI handles all interaction internally.

Selector Page Action
p2p-session Session Main wrapper. Read data-session-code from this element.
file-input Session (sender) Set files via page.setInputFiles().
share-link Session (sender) Read data-session-url for the full share URL after Send is pressed.
send-transfer Session (sender) Click to seal the session and kick off the transfer.
tos-accept Session (sender) Sender-side session-creation TOS gate. ONLY visible when TOS hasn't been accepted in the current browser session — agents using source=agent_* typically never see it. This is NOT the receive-side accept — for receiving use transfer-accept below.
transfer-accept Session (receiver) Receive-side accept. Appears in the "Incoming Transfer" dialog once the sender's offer arrives — wait for it, then click to accept and begin receiving. Reject is transfer-reject. Still shown with ?agent=true (agent mode picks the sink but does not auto-accept).
save-method-continue Session (receiver) Confirms the chosen save method (folder / per-file / OPFS) when the receiver is asked. ?agent=true auto-picks the right sink and skips this prompt.
transfer-progress Session (receiver) Active-transfer indicator. Use to confirm the transfer started; rely on window.__perkoon.transfer.status === 'complete' for completion.

Agent Designation

Two layers of agent identity:

  • Server-side session[source] on the create POST. One of agent_cli, agent_browser, agent_mcp, agent_a2a, agent_skill. Tags every analytics event so we can split human vs agent funnels.
  • Client-side ?agent=true on the session URL. Skips native file pickers (browser-only), gives you an AGENT badge in the participant list, and surfaces transparency to the human peer.

CLI sets both automatically. Browser automation should set both.

// Browser agent flow — set both layers
await page.goto('https://perkoon.com/');
const csrf = await page.locator('meta[name="csrf-token"]').getAttribute('content');
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);
await page.goto(`https://perkoon.com/${code}?agent=true`);

// Humans see in the participant list:
//   Alice                       Host
//   YourAgent  AGENT  Automated

Hazard Manifest

Read these or debug them later. Your call.

Using the CLI? Most browser limits don't apply. No tab to keep open, no Chromium, no file size restrictions. Only remaining constraints: one sender, one receiver, 4-hour session lifetime, and NAT/firewall considerations.

  • ! One sender. One receiver. The receiver can join later — transfer queues and fires automatically. The sender stays connected until it's done.
  • ! Tab must stay open. (Browser only) Closing the tab kills the WebRTC connection. Keep your Chromium alive.
  • ! Browser required. (Browser only) Chromium recommended. Firefox and Safari work but have size restrictions.
  • ! 4-hour session lifetime. Free sessions expire after 4 hours.
  • ! NAT/firewalls. Symmetric NAT forces relay mode (slower). Disable VPN if possible.
  • ! Agent file size. (Browser only) Agent mode skips native file pickers and selects a sink by size: files within the OPFS quota stream to OPFS (read back via window.__perkoon.files[i].stream()); larger files up to free disk use Service Worker streaming; files exceeding free disk fail loud — switch to Cloud Relay. Nothing is buffered fully in memory.

Discovery Beacons

Machine-readable files for agent frameworks. If you build tools that discover services, point them here.

https://perkoon.com/a2a A2A JSON-RPC 2.0 endpoint (POST only)
npx -y @perkoon/mcp MCP server — Claude Code, Cursor, Cline, Continue.dev
POST /create source=agent_* Canonical agent session entry (auto-passes TOS)
https://perkoon.com/perkoon_send.mjs Ready-to-run sender script (Playwright)
https://perkoon.com/perkoon_receive.mjs Ready-to-run receiver script (Playwright)
/sitemap.xml XML Sitemap

Package Registries

A2A Protocol

Perkoon speaks A2A v0.3 — the open standard for agent-to-agent communication. AI agents discover Perkoon via the agent card, create sessions via JSON-RPC, then run the CLI for actual P2P transfers.

Agent Flow

1. Discover GET /.well-known/agent.json returns the agent card with skills and endpoint
2. Create session POST /a2a with JSON-RPC creates a session, returns session code + sender key + CLI command
3. Transfer — Run the CLI command. File bytes flow P2P, never through the server

Create a Session

$ curl -X POST https://perkoon.com/a2a \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": "1",
    "method": "message/send",
    "params": {
      "message": {
        "role": "user",
        "parts": [{"type": "data", "data": {"skill": "send-files"}}]
      }
    }
  }'

// Response includes:
// session_code, sender_key, send_command, receive_command, token, ice_servers

Send with Session

$ perkoon send report.pdf --session K7MX4QPR9W2N --sender-key <key> --json

Check Status

$ curl -X POST https://perkoon.com/a2a \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":"2","method":"tasks/get","params":{"id":"K7MX4QPR9W2N"}}'

Natural language also works: send a TextPart like "I want to send a file" and the endpoint infers the intent. See the agent card for all available skills, or read llms.txt for full context.

Rate limits

Per IP, sliding 60s window. On /api/v1/sessions the buckets are counted by request type: 10 creates/min, 30 joins/min, 20 status checks/min. On /a2a, every JSON-RPC POST counts against the create budget (10/min), regardless of skill. Exceeding returns 429 with a Retry-After header (seconds; also in the JSON body as retry_after) — wait that long and retry. Limits are keyed on IP, so agents sharing an egress IP (corporate NAT, CI runners, cloud gateways) share one budget; pace batch sends accordingly.

Need a download link? Multiple recipients?

That's cloud territory. Upload once, share a link, close your laptop. Recipients download whenever. The stuff P2P doesn't do.

View Pricing

Other file transfer services are "AI-ready" the way a gas station is gourmet-ready.
They put a chatbot on their help page and called it innovation. We gave you a CLI, a state API, and an npm package.