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.
|
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
Browser Automation
Playwright / Puppeteer
- ✓ Full UI control via Playwright
- ✓ Access all browser features
- ✓ Visual verification of transfers
- ! Requires Chromium (~200MB)
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).
Packages & Skills
Published and ready to wire into your stack.
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 ofagent_cli,agent_browser,agent_mcp,agent_a2a,agent_skill. Tags every analytics event so we can split human vs agent funnels. -
Client-side
—
?agent=trueon 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)
/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
GET /.well-known/agent.json
returns the agent card with skills and endpoint
POST /a2a
with JSON-RPC creates a session, returns session code + sender key + CLI command
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.