Skip to main content

Papaya Agent Client

papaya-agent connects a coding harness on your machine to Papaya, so the model works as one of your Papaya agents: it gets that agent's tools, persona, rules and memories, and Papaya can see what it is doing. Two ways to use it, sharing one connection. Interactive: you open Claude Code (or Cursor, or Codex) yourself, and the bundled plugin gives the session the Papaya tools and keeps it in step through hooks. Unattended: papaya-agent listen pulls the agent's event stream, reserves the subject an event is about, and runs a harness for it.

The published Python package is papaya-agent-client, the npm package is papaya-agent, and the command you run is always papaya-agent.

Install

npx papaya-agent connect

That is the whole setup. The npm package is a small shim: it finds uv on your PATH, or downloads one pinned, checksum verified copy into its own directory, and runs papaya-agent-client==<the shim's own version> through it. After a successful connect it runs uv tool install so papaya-agent is on your PATH for good, plus uv tool update-shell once per installed copy. To manage the Python package yourself instead, uv tool install papaya-agent-client and then papaya-agent connect. Either way papaya-agent has to stay on your PATH, because the Claude Code plugin runs it both for its hooks and for the papaya MCP server itself; papaya-agent hooks doctor tells you if it is.

Two environment variables steer the shim, and neither is needed normally. PAPAYA_UV is a path to a uv binary of your choosing, which wins over uv on PATH and over the downloaded copy. PAPAYA_AGENT_SOURCE replaces the package spec the shim installs from: a local path, a git URL, or anything else uv accepts. Without it, a version PyPI cannot resolve is retried once from the matching git tag. The shim needs Node 18 or later and has no dependencies.

Connect

papaya-agent connect signs you in, pins this machine to one Papaya agent, and sets your harness up. --harness chooses between claude (the default), cursor and codex.

Sign-in is a loopback redirect with PKCE. connect binds http://127.0.0.1:<ephemeral port>/callback, asks Papaya where its sign-in page is, opens your browser there, and waits up to five minutes. The link is printed as well as opened, so a browser on another machine works too. What you see:

Waiting for you to approve in the browser…
https://app.trypapaya.ai/cli/authorize?state=...&code_challenge=...
Signed in as Sarah Wolf (sarah@example.com)
Workspace: Papaya HQ (papaya)
Agent: Engineering Agent @eng (engineer)
Connected as Engineering Agent (@eng) in Papaya HQ.
Durable context: available. Read it with the `get_my_agent_context` tool.
Declared to Papaya as runtime claude-code (renews its own leases, rule interception advisory: this session enforces the agent's rules itself).
Token saved to /Users/you/.papaya-agent/connect-token (0600). It is never written into harness config.

The browser authenticates you, and nothing else. The workspace and the agent are chosen afterwards, in the terminal, from the same lists --workspace and --agent filter; a single candidate is auto-picked with a line saying which, and more than one gets a numbered prompt. If you deny the request, or five minutes pass with no answer, connect prints one line and exits 1; a callback carrying somebody else's state is answered and ignored, and the flow keeps waiting. The run ends with the harness instructions, the Claude Code plugin installed, the papaya-agent listen line for that harness (see Listening for events), and one line naming the agent and the workspace and telling you the papaya tools are ready in any repository.

Your token is stored only in ~/.papaya-agent/connect-token and ~/.papaya-agent/config.json, both 0600 (or under PAPAYA_AGENT_HOME; see Environment). connect never writes it into .mcp.json, settings.json, ~/.cursor/mcp.json or any other shared file, and there is no environment variable for you to set anywhere.

Sign-in fallbacks

--device waits for you to approve a short code in the Papaya app, which is what to use over SSH or on a headless machine. It prints Open <link> and approve code ABCD-EFGH, then the plain link and the code on lines of their own so they are easy to copy, then Waiting for approval in Papaya..., and still tries to open the browser unless you pass --no-browser. In this mode you pick the workspace and the agent in the app, so --workspace and --agent do not apply and connect says so. Polling honours the interval the server asks for, doubles the wait (up to 60 seconds) when the server says to slow down, and gives up when the code expires. An expired code, a denied request or a code already used prints one line and exits 1; three network failures in a row exit 2. --password signs in with email and password instead.

Two fallbacks happen on their own, each with a one-line notice: a Papaya server with no browser sign-in route, or a machine that cannot bind a loopback port, falls back to the device code, and a server with neither falls back to the password.

Every connect flag

Flag Meaning
--harness {claude,cursor,codex} Which harness to configure. Default claude.
--workspace <id|slug|name> Pick the workspace without the prompt.
--agent <id|handle|slug|name> Pick the agent without the prompt.
--name <text> Name this connection. Default <harness> on <hostname>, e.g. Claude Code on laptop.
--device Sign in with a device code instead of the browser.
--password Sign in with email and password instead of the browser.
--no-browser Print the sign-in link instead of opening it. Still the loopback flow.
--no-install Do not install the Claude Code plugin, which connect otherwise does for you.
--install Run the harness's own registration command. Cursor and Codex need this; Claude Code installs the plugin without it. Claude Code and Codex then get the papaya-agent listen line to run next.
--print Print only the mcpServers JSON on stdout (status lines go to stderr) and install nothing.
--via-proxy Point the harness at the legacy local proxy. See MCP modes.
--read-only With --via-proxy, expose no write tools. Without --via-proxy it is an error.
--access-token-stdin For a host process that has already signed the person in. Reads their access token from stdin, mints this machine's connection, and stores it. No browser, no prompts, nothing installed, no harness configuration touched. See Running under a host.
--json With --access-token-stdin: print one JSON object (the result, or an error with a code) instead of text.
--working-directory <path> With --access-token-stdin: store this folder as the directory jobs run in and the only one they may use. The same checks as listen --working-directory; a refused folder is an error before anything is read or stored.

What Claude Code gets

connect installs the plugin that ships inside the Python package. To find it and install it by hand:

python -c "from papaya_agent_client.connect import plugin_root; print(plugin_root())"
claude plugin marketplace add <plugin_root>
claude plugin install papaya@papaya-agent-client

The tools

The plugin's .mcp.json registers one server, papaya, running ${PAPAYA_AGENT_BIN:-papaya-agent} mcp passthrough over stdio, and connect writes the same entry for every harness: {"type": "stdio", "command": "papaya-agent", "args": ["mcp", "passthrough"]}, without the type for Cursor and Codex. That command proxies the hosted Papaya MCP server at https://api.trypapaya.ai/api/v1/mcp/ and reads your token out of ~/.papaya-agent/connect-token itself, so no URL and no token appear in any harness config file.

The pass-through connects once before it serves, which does two jobs. A token the hosted server rejects fails there, on your terminal, with Papaya rejected this machine's token. Run papaya-agent connect on stderr and exit 1. And the remote server's startup instructions, which carry your agent's per-connection context, are copied onto the proxy so the model still sees them. Tools, resources and prompts are forwarded as they are, so a tool Papaya adds server side appears without a client release.

The hooks

An interactive session has no wrapper process around it, so the guarantees papaya-agent listen gives an unattended run (reserve, work, release) come from Claude Code's own hooks instead. hooks/hooks.json wires seven events to six bash scripts.

Event Script What it does
SessionStart papaya-session-start.sh Mints this session's reservation session id, appends PAPAYA_AGENT_TOKEN, PAPAYA_WORKSPACE_ID, PAPAYA_SESSION_ID (and PAPAYA_API_URL on a non-production server) to $CLAUDE_ENV_FILE, injects the agent's durable context, and tells Papaya what this runtime can do.
PreCompact papaya-precompact.sh Refreshes and re-injects the context so it survives compaction.
UserPromptSubmit papaya-prompt.sh Injects a header plus the new context when the context changed since this session last saw it. Silent otherwise, which is almost every prompt.
PreToolUse papaya-guard.sh Keeps writes inside configured working-directory roots, then enforces the checkable part of the agent's rules before a Papaya tool runs. Exit 2 blocks the call and tells the model why.
PostToolUse papaya-post-tool.sh Renews the session's reservations mid-turn, at most once every 180 seconds.
Stop papaya-stop.sh Stop Renews every subject reservation this session holds, at the lease length the server granted.
SessionEnd papaya-stop.sh SessionEnd Releases them.

PreToolUse sees every tool so the machine boundary can inspect file and shell writes. The guard itself evaluates agent rules only for mcp__papaya__.* tools. Write containment is independent of the connection's interception capability; an empty working-directory allowlist leaves writes unrestricted. SessionStart and PreCompact get a 15 second timeout, the rest get 10.

Why Stop renews and only SessionEnd releases. Stop fires whenever the model finishes responding, including a pause to ask you a question mid-task, and releasing there would hand the subject to somebody else while the work is half done. So Stop is the interactive equivalent of the listener's renew tick, and SessionEnd, which fires once when the session is over, is the release. For a long turn Stop is not enough, which is what PostToolUse is for; it is gated on ~/.papaya-agent/renew-<claude session id>.stamp (0600), and while that file is younger than 180 seconds the hook exits without starting Python. SessionEnd deletes the stamp.

When a lease goes away. A 409 means another session holds the subject now, so the hook releases nothing: it is no longer ours to release. A reply of renewed: false means the lease was lost too (a person released it, or it expired) and this call has just re-acquired it, so the hook releases it again and a session never re-takes what somebody handed back. Stop and PostToolUse print one line of {"decision": "block", "reason": "..."} on stdout naming each subject, because Claude Code shows the model nothing from a hook that exits 0 and the session would otherwise carry on working a subject it lost. SessionEnd never blocks, and a payload with stop_hook_active set is only logged, because blocking a turn that a block already resumed would loop. All three touch only reservations whose holder.session_id is this session's, so a listener sharing the connection keeps its own; they find each other through Claude Code's session_id, recorded at SessionStart in the per-connection context cache.

Rule enforcement is deliberately narrow. Only a rule carrying an enforce object is mechanical: {"deny_tool": "<glob>"} blocks the tool outright, and {"require_substring": {"tool": "<glob>", "substring": "<str>"}} blocks it unless the serialised tool input contains that substring. The glob is matched with the mcp__papaya__ prefix stripped, so rules read post_message, and rules with no enforce object are for the model to follow. Every hook is fail-soft and enforcement fails open: no cache, no rules, an unreadable payload or any exception allows the call and logs to ~/.papaya-agent/hook.log.

The skill, PAPAYA.md, and checking it worked

skills/papaya-connect/SKILL.md tells the model when to run connect, how to verify with whoami, how to read the full context with get_my_agent_context (resource papaya://me/context), and the memory rules: save_agent_memory for things about the agent itself, propose_memory for the workspace or the people in it. PAPAYA.md is the same guidance as plain instructions, for harnesses that load a file instead of running hooks. papaya-agent hooks doctor prints every hook the plugin registers and whether its script is present and executable, where papaya-agent resolves on your PATH, which connection and agent are current, and the last 20 lines of ~/.papaya-agent/hook.log.

Cursor and Codex

Both get the same papaya server, and neither runs hooks, so both load PAPAYA.md for the persona and memory rules instead. connect --harness cursor --install merges the papaya entry into ~/.cursor/mcp.json; connect --harness codex --install runs codex mcp add papaya -- papaya-agent mcp passthrough, which is where codex exec later finds the server, and ends by printing papaya-agent listen --harness codex. Without hooks nothing renews or releases a subject for those sessions: they rely on papaya-agent listen when a listener drives them, and otherwise on lease expiry or an explicit release_subject.

The whole Codex setup, which is what the app's Connect dialog shows:

uv tool install papaya-agent-client
papaya-agent connect --harness codex --install
papaya-agent listen --harness codex

What a connection tells Papaya about itself

Every connection reports what is on the other end of it, which is how the app can tell an interactive session from a listener, and how Papaya knows whether to enforce the agent's rules itself. The plugin at SessionStart and connect --harness claude both declare runtime_kind: claude-code with heartbeat: true and interception: "advisory", because the PreToolUse hook already enforces the rules locally. --harness cursor and --harness codex declare cursor and codex with heartbeat: false and interception: "enforcing", so Papaya enforces server side. A listener declares its --runtime-kind (default custom) with heartbeat: true and interception: "none", plus delivery, resume and max_concurrent_subjects. SessionStart reports at most once per connection per 24 hours and connect once per run; both are fail-soft.

Durable context from the CLI

papaya-agent context            # the composed block, at most 6000 characters
papaya-agent context --json     # {"content_hash", "changed", "block", ...}
papaya-agent context --refresh  # bypass the cache

context reads the connection the latest connect recorded, asks Papaya for the agent's persona, rules and memories with that connection's token (never asking for sensitive memories), and caches the result per connection at ~/.papaya-agent/context-cache/<connection_id>.json (0600, directory 0700) for 60 seconds, keyed by the hash Papaya returns. An unchanged hash reuses the cached rendering, a changed hash overwrites it, and if Papaya cannot be reached the last cached block is served. --hook {SessionStart,PreCompact,UserPromptSubmit} is how the plugin's own scripts call it: it prints the hook JSON on stdout and never fails a session.

Listening for events

papaya-agent listen runs the connected-agent event loop for the connection connect made. Every turn has the same shape, whatever harness you point it at: pull events, apply the playbook, reserve the subject, run your command, renew the lease while it runs, release on exit, advance the cursor. HTTP polling is the contract; the WebSocket only carries a doorbell saying "go pull", so the listener reacts at once but never depends on the socket. At startup it reports its capabilities to Papaya, fail-soft.

Flag Default Meaning
--harness {claude-code,codex} claude-code Which bundled runner drives events: papaya-claude-runner.sh or papaya-codex-runner.sh. Exits 1 before connecting if that harness's CLI (claude or codex) is not on PATH.
--run '<command>' the harness's bundled runner Your own harness command instead. {prompt} is replaced with the generated instruction; without it the prompt goes to stdin. No CLI is looked for.
--poll-interval N 20 (or PAPAYA_POLL_INTERVAL) Seconds between event pulls.
--max-concurrent N 2 Subjects worked on at once.
--max-event-age SECONDS 720 (or PAPAYA_MAX_EVENT_AGE) Skip conversational act events (mention.you, dm.received) Papaya wrote longer ago than this, as already handled. Assigned work never ages out. 0 turns the check off. See Work Papaya already handled.
--runtime-kind KIND the harness's name, or custom with a bare --run Label reported to Papaya. An explicit --harness labels a --run command too.
--resume off Advertise that this runtime can resume a previous session.
--match REGEX none Only act on events matching. Repeatable, and every pattern must match.
--exclude REGEX none Never act on events matching. Repeatable.
--explain-match FILE none Print the canonical string and the verdict for a saved envelope, then exit.
--supervised off Run under a host process: a JSON Lines protocol on stdout and stdin, every job approved by the host before it starts, human output on stderr. See Running under a host.
--approval-timeout SECONDS 120 With --supervised: how long a job waits for the host's decision before it is denied.
--working-directory PATH the configured directory, else the current directory Run every job in this folder and allow no other, overriding the configured directories for this run. See Where jobs run. Required with --supervised unless connect stored one.
--hand-back-on-shutdown off When the listener stops (shutdown, SIGTERM, SIGINT, a closed stdin), hand back every work item this machine keeps. Without it a machine that stops keeps its work items. See Handing a work item back.

Jobs have no hard wall-clock ceiling by default (timeout_seconds: 0); a positive configured value remains an opt-in ceiling with the existing timeout message. Liveness instead comes from each Claude Code hook and each line the harness streams on stdout/stderr. After 30 quiet minutes (PAPAYA_STALL_AFTER=1800) the listener logs and reports job.stalled but continues renewing the lease. If no activity arrives during the separate 10-minute grace (PAPAYA_STALL_GRACE=600), it hands the subject back and stops the harness with outcome stalled. Either variable accepts seconds; 0 disables that step. Codex has no client hook, so Codex and custom harnesses are judged from streamed output after their initial process-start activity.

Where jobs run

A harness runs with full access to the files under its working directory (the bundled Claude runner uses bypassPermissions), so that directory should be one you chose. --working-directory PATH names it:

  • PATH must be absolute, must exist and must be a directory. Symlinks are resolved, and the resolved folder is what jobs use.
  • It is refused if it is /, your home directory itself, any directory above your home directory, or one of /System, /usr, /bin, /sbin, /etc, /Library, /Applications, /private, /var or /tmp (and the /private forms macOS resolves those to). Folders inside any of them, such as ~/code/billing, are fine.
  • It becomes the only allowed directory for the run, whatever config.json lists. A job that asks for a directory (a relative or absolute hint) runs there only when the hint resolves inside it; otherwise the job is refused and handed back as declined, exactly like a job the harness declines.

A refused --working-directory exits 2 with the reason and its code on stderr: working_directory_not_absolute, working_directory_not_found, working_directory_not_a_directory or working_directory_refused. Without the flag, a terminal listener keeps using the configured directory, or the current directory when none is configured.

listen --supervised has no such fallback: a host app launched from Finder has / as its current directory. It needs --working-directory, or a directory stored by connect --access-token-stdin --working-directory, and otherwise stops with working_directory_required before it listens.

Claude Code and Codex are two bundled runners behind one flag, and the lines connect --install printed are the ones to run:

papaya-agent listen                   # Claude Code, the default
papaya-agent listen --harness codex   # Codex

Both runner scripts ship inside the wheel, so neither line needs a path, from an installed copy or a source checkout. --harness is also the runtime label the listener reports, so a listener and an interactive session for the same tool show up under one name in Papaya. If the harness's CLI is not on PATH the listener says so (Codex is not installed: ... is not on PATH.) and exits 1 before it connects. --run still takes any command of your own, {prompt} included, and is never checked against a binary:

papaya-agent listen --run './my-runner.sh {prompt}'                  # reported as custom
papaya-agent listen --harness codex --run './my-codex.sh {prompt}'   # reported as codex

The bundled Claude runner reads one environment variable of its own, PAPAYA_CLAUDE_PERMISSION_MODE, which is the --permission-mode it passes to claude. It defaults to bypassPermissions; set it to acceptEdits, plan or default for a stricter mode. The bundled Codex runner runs codex exec, with --cd set to the working directory, and leaves the papaya server to Codex's own config, which connect --harness codex --install registered.

Runtime directories

A Papaya Agent Runtime checkout (a directory with a bin/ppy launcher and a .ppy/ state directory) runs the event loop itself, one long-lived process for many events rather than a harness session per event. When the directory listen would work in is one of those, this client does not listen at all: it replaces its own process with ./bin/ppy serve, passing through every listen argument it was given, its environment plus PAPAYA_HOST_CLIENT_VERSION, and its stdio. Nothing is spawned, so a host app that started papaya-agent is supervising the runtime under the same pid, pipes and signals. This applies to listen and listen --supervised alike, and the directory is the one --working-directory names, else the one connect stored, else the current directory.

What the checkout supports is what it says. Both connect and listen run ./bin/ppy capabilities --json in it and read the answer:

{"runtime":"papaya-agent-runtime","version":"0.3.1","client_version":"0.15.1","protocol":1,"modes":["terminal","supervised"]}

connect records that under runtime in the agent's config and writes no command, because there is no harness command to run in a runtime directory. A command stored by an earlier connect is left alone, reported, and unused.

The runtime's launcher builds its own environment the first time it is used, so that first answer can take minutes on a machine with a cold cache. The probe waits ten seconds and, only if that wait runs out, says so and asks once more with a three minute wait: a line on stderr in a terminal, and under --supervised one {"type":"status","phase":"connecting","detail":"preparing_runtime"}. A launcher that exited non-zero or printed something other than JSON has answered, so it is refused at once and never asked twice.

listen hands over only when the reported protocol is the one this client speaks and modes lists the mode being started. Otherwise it stops: exit 1, with the directory and the reason on stderr, or, under --supervised, as a fatal error with code runtime_unavailable. A failed probe (a launcher that cannot run, a non-zero exit, output that is not JSON, or no answer to either wait) is refused the same way. There is no falling back to a session per event, because that is the failure the runtime exists to prevent.

The playbook

Each event kind maps to act, acknowledge or ignore. The mapping comes from the agent's resolved playbook, edited in the Papaya app and merged by the server over the defaults, which are act for mention.you, work_item.assigned, work_item.comment, dm.received and approval.waiting, acknowledge for work_item.status_changed, and ignore for subject.reserved and subject.released. No model call is made to decide.

acknowledge advances the cursor and does nothing else; ignore does the same and does not log it as interesting. An unknown kind is ignored, and an act on an event the server did not mark reservable, or that carries no subject, is downgraded to acknowledge, because the listener never reserves a subject the server did not offer a lease on.

Which events are this machine's

One Papaya agent can have a listener on every machine you own, and only one of them has the backend checked out. --match and --exclude are how a listener says what it can actually do. Both are Python regexes (re.search, case-insensitive), both repeat, and both are tested against one canonical string per event: "<kind> <subject> " + json.dumps(payload, sort_keys=True).

# The bug machine: only work items whose kind is "bug".
papaya-agent listen --match 'work_item.*"kind": "bug"'

# The backend machine: anything except the frontend.
papaya-agent listen --exclude 'frontend'

Every --match must hit and no --exclude may, so patterns narrow rather than widen, and an event that hits both is refused: saying "never this" is the stronger statement. An event that does not pass is treated exactly like an ignore, so the cursor advances, nothing is reserved, and one line is logged ([listen] event=4512 not mine (--match/--exclude): ...). The filter is local to the machine: the server never sees it, so it never routes around it, and only events the playbook already resolved to act reach it.

To test a pattern without running anything, save an envelope (any run leaves one at $PAPAYA_EVENT_FILE) and ask papaya-agent listen --explain-match ./envelope.json --match '"kind": "bug"'. It prints the canonical string and then Verdict: run or Verdict: not mine (--match/--exclude), and exits 0 when the event would be worked here, 1 when it would not, and 2 when the file is not a readable envelope. A malformed pattern is rejected before anything connects.

Subject reservations

A subject (work_item:<uuid>, thread:<uuid>, dm:<uuid>, proposal:<uuid>) is the unit of work, and only one session works a subject at a time. Before running, the listener reserves it; if another session already holds it, the holder is logged, the event is skipped, and the cursor moves on, with no retry storm and no jitter. While the harness runs, the lease is renewed every third of the granted lease length, and network failures are retried on the next tick, because the server-side expiry is the safety net and a flaky network should not kill good work. On any exit (clean, crash or SIGTERM) it is released.

A renewal that comes back 409 means the lease was lost: the harness is killed and the subject is not released, because this session no longer owns it. A renewal that comes back renewed: false means the lease was lost too and this call has just taken the subject again, so the harness is killed and the subject is released, because the accidental re-acquire made this session the holder. That second case is how a person releasing a subject stops the holder: reserving is acquire-or-extend, so the next renew would otherwise quietly take the subject back and carry on working on something a person had deliberately taken away.

Losing a race is not the end of it. A subject this listener wanted but lost is remembered with the event that asked for it, up to 200 subjects, oldest forgotten first. When a subject.released arrives for one of them, the listener bids again and runs the original event, not the release notice ([listen] event=4530 re-bid on work_item:5d0c... after expired). expired, revoked and declined invite a re-bid. holder and human drop the entry instead: the work is done, or somebody decided on purpose that no agent should be doing it. A re-bid is a run like any other, so it waits for a free slot and is skipped again if a faster peer got there first.

A harness can say "not mine" itself. Some things are only knowable once the run has started: wrong repository, wrong branch, a machine without the tool the ticket needs. Every run gets a private, empty file named by $PAPAYA_DECLINE_FILE; a run that writes a reason there, or a harness command that exits with status 75, says exactly that, so the subject is released as declined, the one release reason that invites peers to re-bid, the reason is logged, and the event is not run again here. Every other ending, including a crash, releases the subject as usual. The generated prompt tells the model to write the file, because a model cannot set its process's exit status: a session that only said it exited 75 used to end with status 0 and have its subject released as done. The bundled runners also exit 75 when the file is non-empty.

An event for another identity never runs. The envelope names the agent and workspace it was written for. If either differs from this connection's, the listener logs why and moves on without reserving the subject or starting a harness (identity_mismatch). The bundled runners check the same thing again for the MCP server they are about to hand the job, and decline (exit 75, reason in the decline file) if it is not this connection's.

Work Papaya already handled

Papaya routes an agent's work to a machine only while that machine is on call (a listener that has pulled within the last 90 seconds). Events are written to the agent's stream either way, and with no machine on call the agent in Papaya handles them itself. A listener that starts later, or comes back after its machine slept, would find that backlog waiting and answer it a second time. Three rules keep it from doing that:

  • Old conversation is skipped; assigned work is not. A conversational act event (mention.you, dm.received) whose occurred_at is more than --max-event-age seconds old (default 720, twelve minutes, the same window Papaya waits for a machine to pick a job up before handing it to the agent in Papaya) is treated like an acknowledge: the cursor moves past it, nothing is reserved, and one line is logged ([listen] event=4812 subject=thread:5d0c... is 47m12s old; skipping it as already handled). Answering a chat message hours late is worse than not answering it. Durable work is the opposite and ignores the window entirely: work_item.assigned, work_item.comment, work_item.status_changed and approval.waiting are still true however old the event is, so a listener that sat at capacity, or a machine that was asleep, for longer than the window picks them up rather than losing them. The age is server_time on the events page minus the event's occurred_at, both Papaya's clock, so a machine whose own clock is off still judges it correctly. Only when a page carries no server_time is this machine's clock used instead. An event with no occurred_at is never skipped for age. A subject.released that would invite a re-bid is judged by the kind of the work it releases: a late release of a stale mention does not bid again, a late release of assigned work does.
  • A first start does not replay history. With no stored cursor for the connection (a new connection, or a missing or unreadable cursor file), the listener's first pull pages through the stream to its head without acting on anything, stores and acks that cursor, and works only events that arrive after it ([listen] No stored cursor: starting at the head of the stream (cursor 4830); 212 earlier event(s) not replayed). Papaya has no "latest cursor" read, so this costs one pass over the stream, 200 events a page, once.
  • A subject Papaya took over is not a lost race. When the agent in Papaya took a job over from the agent's machines, Papaya refuses that subject to them for 24 hours, naming the holder papaya-hosted. The listener logs already handled in Papaya, moves on, and does not remember the subject for a re-bid.
  • Work sent to someone else is not a lost race either. When several people run the same agent, Papaya gives each piece of work to one person's machines: the person who triggered it, else (for a work item) its assigner, requester or previous owner, else the agent in Papaya. A work item another machine keeps stays with that machine. A running machine that asks for anything else is refused with a 409 whose holder session_id is not-routed-to-this-machine, routed or held. The listener logs was not sent to this machine (<machine> has it), moves on, and does not remember the subject for a re-bid. A supervised host gets job.skipped with reason not_routed_here, and no error.

A page with more events behind it is followed at once rather than after the poll interval, so a listener working through a backlog reaches fresh events before they age out.

Handing a work item back

Once a running machine takes a work item, Papaya keeps it with that machine's connection: later comments on it, and reassignments back to the agent, come to the same machine first, even while it is offline (the agent in Papaya covers once and says the item stays with the machine). A machine gives a work item back when it should not keep it:

papaya-agent hand-back --subject work_item:5d0c1e2a-3b4c-4d5e-8f60-718293a4b5c6
papaya-agent hand-back            # every work item this connection keeps
papaya-agent hand-back --json

It uses the connection in PAPAYA_AGENT_HOME (default ~/.papaya-agent) and calls POST /api/v1/workspaces/{workspace}/subjects/hand-back. With a subject, that work item's hold ends and a run this connection has on it is released as declined, so waiting work goes to the agent in Papaya at once; without one, every hold ends and runs are left alone. It is always safe to run: handing back something this machine does not keep is not an error. --json prints {"ok":true,"subject":...,"handed_back":true,"holds_ended":1,"released_run":false}, or {"ok":false,"error":{"code":...,"message":...}} with invalid_subject (exit 2), invalid_endpoint (2), not_connected (1), token_rejected (1) or api_error (2).

The command does not stop a run another listen process is working; that listener stops it when its next lease renewal is refused. A host running listen --supervised sends the protocol's hand_back message instead, which stops the run at once (see docs/supervised-protocol.md).

A job can hand its own work item back too: for a work item, the generated prompt tells the model to call the hand_back_subject tool (the job's Papaya MCP server has it) when the item cannot be finished on this machine, and then to decline.

Stopping does not hand anything back. A listener that stops (Ctrl+C, SIGTERM, a host's shutdown) keeps its work items, because a laptop that closes for the night should still be the machine its work items come back to. Pass listen --hand-back-on-shutdown for a machine that should give them all back whenever it stops.

The cursor

The client-held cursor lives at ~/.papaya-agent/events-cursor-<connection-id>.json (0600). It only ever passes an event the listener has finished with, so a listener that dies mid-run re-reads that event on restart, provided it is still inside the staleness window; delivery is at-least-once and the server is idempotent. After each handled batch the listener also acks the cursor server side. A missing or corrupt cursor file never stops the listener: it starts at the head of the stream, as above. When every worker slot is busy the remaining events are left in the stream for the next pull.

What the harness receives

The command runs with {prompt} replaced by a short generated instruction: the playbook guidance, the event kind and subject, the triggering text, how to hand the work back, the closing steps, and a pointer to the envelope. It is capped at 2,000 characters, and truncation trims the free text rather than these three closing lines (four for a work item, whose prompt first says how to hand it back: call hand_back_subject with its subject, then decline):

If this event is not yours to act on (another agent or workspace, wrong repository, wrong machine, out of scope), write a one-line reason to the file named by $PAPAYA_DECLINE_FILE and stop without doing anything else.
Before you finish: propose durable learnings (propose_memory for the workspace or people, save_agent_memory for yourself) and leave a short closing comment on the subject.
The full envelope is at $PAPAYA_EVENT_FILE.

Writing memories back stays the model's job and stays proposal-only: no hook and no part of the listener writes a memory. Everything else arrives in the environment:

Variable Contents
PAPAYA_EVENT_FILE Path to the full JSON event envelope (0600, deleted on exit).
PAPAYA_EVENT_KIND The event kind, e.g. work_item.assigned.
PAPAYA_SUBJECT The reserved subject.
PAPAYA_WORK_ITEM_ID The work item UUID, when the subject is a work item.
PAPAYA_SESSION_ID This listener process's session id (<hostname>-l-<8 hex>).
PAPAYA_AGENT_TOKEN The pagc_ token for this connection.
PAPAYA_WORKSPACE_ID The workspace UUID.
PAPAYA_API_URL The Papaya API base URL.
PAPAYA_CONTEXT_FILE Path to the rendered durable-context bundle (0600, deleted on exit).
PAPAYA_JOB_ACTIVITY_FILE Private liveness stamp touched by listener-job Claude hooks and every streamed output line (0600, deleted on exit).
PAPAYA_WORKING_DIRECTORY The resolved working directory, which the Codex example passes to --cd.
PAPAYA_DECLINE_FILE An empty private file (0600, deleted on exit). A reason written here declines the job.
PAPAYA_AGENT_ID, PAPAYA_AGENT_REF This connection's agent, which the runners check the job's MCP identity against.
PAPAYA_PLUGIN_DIR The Claude Code plugin bundled with this client, which the Claude runner loads.
PAPAYA_JOB_ID The job id (event-<id>). The plugin's hooks switch to listener-job behaviour on it.

When the subject is a work item, papaya-claude-runner.sh reuses that UUID as the Claude session id, so a follow-up event resumes the same conversation. To invoke a harness differently, point --run at your own copy of its runner.

What an unattended job loads

A job acts as this connection and nothing else, whatever else the machine has configured. Both CLIs normally also load the person's own MCP servers, and a papaya server left there by an earlier terminal connect is another agent, possibly in another workspace or environment. So the bundled runners ask papaya-agent mcp runner-config for each job's MCP configuration:

Claude Code (papaya-claude-runner.sh) Codex (papaya-codex-runner.sh)
Papaya MCP tools One papaya server: papaya-agent mcp passthrough --agent <ref>, pinned to this home (PAPAYA_AGENT_HOME) and endpoint, so mcp__papaya__* is this connection's full hosted tool set. One papaya-job server: this connection's Papaya MCP URL with bearer_token_env_var = "PAPAYA_AGENT_TOKEN" (the pinned pass-through when no token is exported).
The person's other MCP servers Not loaded: --strict-mcp-config skips user, project and local servers, plugin servers and claude.ai connectors. PAPAYA_RUNNER_INHERIT_MCP=1 copies the non-Papaya ones into the job's config. Switched off: every [mcp_servers.*] in ~/.codex/config.toml and the project's .codex/config.toml gets -c mcp_servers.<name>.enabled=false. PAPAYA_RUNNER_INHERIT_MCP=1 switches off only the Papaya ones.
An existing Papaya entry for another identity Never loaded. Always switched off, inherit or not. One whose name codex -c cannot address (dots, quotes) stops the job with an error instead.
Skill and hooks The plugin bundled with this client version, via --plugin-dir (which also shadows a papaya plugin installed globally for another identity). In a job the hooks import context and enforce rules for this home, but do not mint a session, declare the runtime, renew or release: the listener owns those. PAPAYA_RUNNER_NO_PLUGIN=1 skips it. None: Codex has no Papaya hooks. The persona and rules arrive through the MCP server's instructions and get_my_agent_context.
Durable context SessionStart hook block, $PAPAYA_CONTEXT_FILE, and get_my_agent_context. $PAPAYA_CONTEXT_FILE and get_my_agent_context.
Identity check runner-config exits 75 (reason in $PAPAYA_DECLINE_FILE) when the --agent ref resolves to a different agent or workspace than PAPAYA_AGENT_ID/PAPAYA_WORKSPACE_ID. The same, and also when $PAPAYA_AGENT_TOKEN is not that agent's token.

A custom --run command gets the environment and the decline file, but no MCP isolation unless it calls papaya-agent mcp runner-config itself.

Legacy: dispatched jobs

There is an older model in which Papaya dispatches a job to this machine and the client runs a configured local command for it. It is still supported, and listen uses it when this machine has no connection from papaya-agent connect, meaning a machine set up with papaya-agent login instead. The client opens one WebSocket per configured agent, drains queued jobs on startup and reconnect, runs the agent's configured command, and posts the result back.

The command is an argument array and is never run through a shell: an argument containing {prompt} receives the job prompt, otherwise the prompt goes to stdin, and {job_id}, {runner_session_key}, {runner_session_id}, {work_item_id} and {runner_session_file} are substituted the same way. Job working-directory hints must resolve inside the agent's allowed directories: a hint outside them runs in the configured directory when that one is allowed, and otherwise the job is completed as failed without running. With --working-directory, a hint outside that folder is refused outright. Each claimed job leaves its prompt and directory resolution metadata in ~/.papaya-agent/job-logs/<job-id>/, and PAPAYA_AGENT_LOG_FULL_PROMPT=true also prints the whole prompt to the console.

Follow-up work from the same work item can carry a prior session id, which reaches the command as PAPAYA_RUNNER_SESSION_KEY, PAPAYA_RUNNER_SESSION_ID and PAPAYA_RUNNER_SESSION_FILE, alongside PAPAYA_JOB_ID, PAPAYA_EXTERNAL_JOB_ID and PAPAYA_WORK_ITEM_ID. A wrapper should resume the external session when PAPAYA_RUNNER_SESSION_ID is set and write the current session id to PAPAYA_RUNNER_SESSION_FILE when it finishes, so the next dispatch resumes it. scripts/papaya-claude-runner.sh does that: configure it as the agent's command with the Papaya agent_ref as its one argument (["/path/to/scripts/papaya-claude-runner.sh", "scout"]), and with no prompt argument it reads the prompt from stdin, which is what this path does.

Running under a host

Papaya Desktop keeps a Mac on call without a terminal by running this client as a child process. Three pieces make that work, and a terminal user needs none of them:

# A home of its own, so it never shares a token, cursor or log with a terminal client.
export PAPAYA_AGENT_HOME="$HOME/Library/Application Support/Papaya/agent-client"

# Connect once without a browser. The access token is written to stdin.
papaya-agent connect --workspace papaya --agent scout \
  --access-token-stdin --no-install --json \
  --working-directory "$HOME/code/billing"

# Listen in that folder, approving each job.
papaya-agent listen --supervised --working-directory "$HOME/code/billing"

connect --access-token-stdin takes the access token of a person the host has already signed in, uses it for the same workspace, agent and connections/ensure calls the browser flow ends with, and stores this machine's own pagc_ token under PAPAYA_AGENT_HOME. It never stores or refreshes the access token, and prints neither token. With --json it prints one object, {"ok": true, ...} or {"ok": false, "error": {"code": ..., "message": ...}}, and exits 0, 1 (refused) or 2 (bad arguments or a server failure). More than one matching workspace or agent is an error rather than a prompt. --working-directory stores the folder jobs run in, and the result reports it as working_directory.

listen --supervised writes only JSON Lines to stdout (hello, status, job.request, job.skipped, job.started, job.stalled, job.stall_cleared, job.finished, hand_back.result, error) and reads job.decision, hand_back and shutdown from stdin. A job runs only after the host answers its job.request with "allow": true. A denial, no answer within --approval-timeout, a closed stdin, a malformed message, a lost lease or a shutdown all mean the job does not run, and the subject is released as declined, the reason that invites another listener for the agent to take it. Supervised mode refuses to listen without a working directory (see Where jobs run); hello and every job.request carry the resolved working_directory, so the host can show where a job will run. Every message, the approval rules and what happens to the cursor are in docs/supervised-protocol.md.

{"type":"hand_back","subject":"work_item:<uuid>"} gives one work item back and, when Papaya released this machine's run on it, stops that run (job.finished with outcome handed_back); leave subject out to give back every work item. Each is answered with hand_back.result. shutdown alone keeps the machine's work items, so Papaya Desktop's "Stop running on this Mac" sends hand_back with no subject first, then shutdown.

A host can also show the Papaya MCP entries the person's harnesses already carry, and who each one acts as, since an entry left by an earlier terminal connect makes an interactive session talk to Papaya as another agent:

papaya-agent harness-conflicts --harness claude --working-directory "$HOME/code/billing" --json
papaya-agent harness-conflicts --remove "claude:user:papaya" --json

The listing covers Claude Code's user, local and project scopes, Codex's user and project config, and Cursor's user and project mcp.json, and resolves each token's agent and workspace with a read-only whoami (never printing a token). --remove prefers claude mcp remove or codex mcp remove, otherwise edits the file after a timestamped backup, and refuses the entry this home's connection owns. The JSON shape and error codes are in docs/supervised-protocol.md.

Embedding the listener

A Python host can run the event loop in its own process instead of starting papaya-agent listen as a child, and execute each approved job however it likes. papaya_agent_client.embed is the whole surface:

from papaya_agent_client.embed import build_listener

async def my_runner(job):
    # `job` is a `listener.Job`: the event envelope, the playbook decision, the
    # generated prompt, the job id, the working directory, the environment a
    # harness would have been given, the output log path and the job's stop
    # signal. Answer the dict `run_command` answers.
    job.touch_activity()          # still alive, so the stall check stays quiet
    job.report_progress("planning", "reading the failing billing test")
    ...
    return {"request_id": job.job_id, "exit_code": 0, "output": "", "error": None}

listener = await build_listener(
    runner=my_runner,
    home="/path/to/agent/home",            # the `PAPAYA_AGENT_HOME` rule
    working_directory="/path/to/project",  # then the only allowed root
    session_id=stored_session_id,          # optional; see below
    runtime_kind="my-runtime",             # what the connection announces
)
await listener.loop.run()

Everything the terminal listener does is intact, because it is the same loop: the cursor, the reservation and its renewal, lost-lease detection, staleness classes, stall observation and the grace hand-back, hand-back on demand, and the supervised protocol. A runner replaces only the subprocess.

job.report_progress(phase, detail) is how a runner says what it is doing across the hours it holds a work item: a supervised host receives it as a job.progress message, a terminal listener logs it, and either way it counts as activity, so a job that reports is never observed as stalled.

Four things are worth knowing. A runner answers exit code 75, or writes a line to the file named by PAPAYA_DECLINE_FILE (job.decline("...")), to say the work is not this machine's, exactly as a harness does; the subject then goes back as declined. A runner that raises fails its job, releases the subject and leaves the loop running. The job's four temporary files live only for the length of the call. And session_id is the lease identity: a host that stores one across restarts takes its own leases back instead of waiting for them to expire, while a host that does not gets a fresh id per process, like the CLI.

build_supervised_listener(writer, stdin_fd=...) is the same wiring with a Supervisor bound to the host's stdio, so a host that wants to approve each job before it starts gets the protocol below without reimplementing it. Both entry points raise ListenerSetupError (with code, message and status) for a home with no connection, a refused working directory or a missing harness CLI. papaya-agent listen and listen --supervised are argument parsing, a banner and a websocket around these two calls, so there is one wiring, not two.

Managing agents and sessions

Command What it does
papaya-agent login Sign in with email and password, then configure local agent connections for the legacy job model. It replaces the whole configured set, and does not open WebSockets or start listening.
papaya-agent login --token-stdin [--workspace <slug>] Store a connection token the Papaya app generated for a headless machine, read from stdin (a hidden prompt in a terminal). It asks Papaya which agent, workspace and connection the token is for, refuses it if --workspace names a different workspace, and leaves the machine ready for papaya-agent listen. --token <token> is accepted too, with a warning, because an argument is visible to other processes and stays in shell history. --json prints one JSON object.
papaya-agent agents add The same configuration flow, merging into what is already there. Re-running it for an agent that already has a connection rotates that connection's token.
papaya-agent agents edit Edit one configured agent's command, working directory, allowed directories and timeout. Choose exactly one.
papaya-agent agents remove Remove configured agents.
papaya-agent logout Clear the saved user session and every configured agent. It does not delete ~/.papaya-agent/connect-token; run connect again to replace that.
papaya-agent workspaces List workspace IDs, slugs and names for the saved user session.
papaya-agent context Print the connected agent's durable context. See above.
papaya-agent hand-back [--subject work_item:<id>] [--json] Stop keeping a work item (or every one) on this machine. See Handing a work item back.

For each agent, login and agents add ask for a display name, the command to run when Papaya dispatches a job (default claude -p {prompt} --enable-auto-mode), a working directory, the allowed working directories, and a timeout in seconds (0, meaning no hard ceiling, by default). Your password is never stored. The saved config lives at ~/.papaya-agent/config.json (0600) and holds the user refresh token plus one scoped pagc_... token per configured agent; rotating a connection invalidates that connection's old token and no other machine's.

workspaces prints a column-aligned table of IDs, slugs and names by default, one JSON object per line with --json, and IDs only with -q. Without a saved user session it prints Run papaya-agent login first. and exits 1.

Environment

None of these are needed normally. Each is read on every command, so switching is just the variable.

Variable Default Meaning
PAPAYA_ENV production production (or prod), staging or local. Staging is https://api-staging.trypapaya.ai, local is http://localhost:5173. Any other value is an error, so a typo never quietly means production.
PAPAYA_API_URL from PAPAYA_ENV The API origin, for example https://api-staging.trypapaya.ai. Wins over PAPAYA_ENV. Must be https:// unless the host is localhost, 127.0.0.1 or ::1, and must carry no credentials, query or fragment. The WebSocket URL is derived from it (https to wss).
PAPAYA_WS_URL derived Only when the WebSocket lives somewhere other than the API origin. wss://, or ws:// for a loopback host.
LOCAL_PAPAYA unset true is the older spelling of PAPAYA_ENV=local, and loses to both variables above.
PAPAYA_MAX_EVENT_AGE 720 Seconds after which listen skips a conversational act event as already handled; assigned work never ages out. --max-event-age wins. 0 turns the check off.
PAPAYA_STALL_AFTER 1800 Quiet seconds before a running job is observed as stalled. 0 disables stall detection.
PAPAYA_STALL_GRACE 600 Further quiet seconds after the stall observation before hand-back and harness stop. 0 makes hand-back immediate.
PAPAYA_AGENT_HOME ~/.papaya-agent Where every file the client reads or writes lives: config.json, connect-token, the event cursors, the context cache, hook.log, renew stamps and job logs. Use an absolute path.
PAPAYA_HOST_CLIENT_VERSION unset Set by this client, not for you: the version of the client that handed listen over to a runtime's ./bin/ppy serve. See Runtime directories.

There is no variable for the web app's address. connect asks the API where its sign-in page is, and staging's API answers with https://app-staging.trypapaya.ai/cli/authorize. A bad value exits 2 with one line naming the variable.

MCP modes

papaya-agent mcp passthrough is the default: the full hosted tool set, proxied over stdio, with the token read from ~/.papaya-agent/connect-token. It is what the plugin runs and what connect writes into every harness config, and --agent <ref> serves a configured agent's stored token instead, so one machine can serve a second agent without re-running connect. The only reasons to choose papaya-agent connect --via-proxy instead are acting_as on every write result, so the harness can see which Papaya agent it just spoke as, and --read-only, which exposes no write tools at all.

The legacy local proxy

papaya-agent mcp is a hand-built proxy that predates the pass-through, kept for those two things alone, and its tool set is a hand-maintained subset rather than whatever the hosted server publishes. connect --via-proxy emits it as papaya-agent mcp --agent <ref> [--read-only], and with --install registers it through claude mcp add or codex mcp add (or, for Cursor, by merging into ~/.cursor/mcp.json). Run directly, exactly one of these is required:

  • --agent <ref>: one assistant acts as one Papaya agent. Tool calls need no agent_ref, which makes it the least ambiguous mode. Add --read-only for a session that must read Papaya but not write to it.
  • --multi-agent: one assistant coordinates across several Papaya agents. Tool calls require agent_ref.
  • --user: expose connected-service tools using your saved Papaya user session rather than an agent identity. Add --workspace <id> if the account has more than one workspace.

The tools it exposes are papaya_whoami, papaya_list_channels, papaya_read_channel_messages and, unless --read-only, papaya_post_message; --multi-agent adds papaya_list_agents. Connected-service tools (for example github__..., linear__..., google__..., trello__...) are loaded in --agent and --user mode only, never in --multi-agent mode, and only for providers the workspace has connected. Under --read-only those are filtered by name, which is a heuristic: a tool whose name reads as a write is dropped.

papaya-agent mcp list-agents                        # agent_ref, name, workspace id
papaya-agent mcp print-config --agent scout         # one pinned server
papaya-agent mcp print-config --multi-pinned        # one server per configured agent
papaya-agent mcp print-config --multi-agent         # one shared server
papaya-agent mcp print-config --user --workspace <workspace-id>
papaya-agent mcp persona-prompt --agent scout       # instructions pinning a shared server to one identity
papaya-agent mcp doctor --agent scout               # diagnostics, no raw tokens

print-config also takes --read-only, and persona-prompt requires --agent. The agent that papaya-agent connect pinned is always in list-agents, even on a machine with no agents block in ~/.papaya-agent/config.json: its identity comes from the connect record and its token from ~/.papaya-agent/connect-token. That derived entry is rebuilt on every load and never written back into config.json, which is what makes papaya-agent connect --via-proxy work with nothing else set up.

Troubleshooting

No papaya tools after connecting. Run papaya-agent connect again and restart the harness. There is no environment variable to set and nothing to put in a shell profile; the two things worth checking are that papaya-agent is on your PATH and that the plugin is installed, and papaya-agent hooks doctor answers both.

A Papaya tool returns 401, or the server says the token was rejected. The connection's token is stale: run papaya-agent connect again and restart the session. The pass-through says the same on its stderr and exits 1 rather than serving a broken session.

papaya-agent: command not found, or the harness cannot find it. The harness starts the CLI by name, so it has to be on the PATH the harness inherited. Install it with uv tool install papaya-agent-client and open a new terminal, or set PAPAYA_AGENT_BIN to an absolute path; hooks doctor prints which of those is in effect.

A machine with no browser (SSH, a container, a server). Use papaya-agent connect --device and approve the short code in the Papaya app, or papaya-agent connect --no-browser and open the printed link from another machine.

A Papaya server without the browser sign-in route. connect falls back on its own, printing This Papaya server has no browser sign-in yet; using the device code instead. and continuing with the device flow; a server with no device flow either falls back again to email and password.

listen says the harness is not installed. --harness resolves a bundled runner that starts claude or codex by name, so that CLI has to be on the PATH of the terminal running listen. Install it and run listen again, or pass --run '<command with {prompt}>' to drive something else.

The listener skipped an event as "already handled". It was a conversational event older than --max-event-age (twelve minutes by default), or Papaya had already handed the subject to the agent in Papaya. Either way the agent in Papaya answered it, which is what happens to any work that arrives while no machine is on call. A listener that has been off for a while, or is starting for the first time, does not catch up on that work on purpose.

The listener says an event is "not mine". That is --match or --exclude refusing it. Check the pattern with papaya-agent listen --explain-match <envelope.json>, which prints the string your pattern is tested against.

Developing this client

Repository layout

Path What is in it
papaya_agent_client/__main__.py The argparse tree, the authority for every command and flag, plus login, logout, workspaces and the legacy job loop.
papaya_agent_client/connect.py connect: sign-in flow selection, workspace and agent choice, harness config, plugin install.
papaya_agent_client/browser_signin.py The loopback listener and the PKCE sign-in.
papaya_agent_client/auth.py Password sign-in and session refresh.
papaya_agent_client/api_client.py Every Papaya HTTP call, and the typed errors callers branch on.
papaya_agent_client/config.py PAPAYA_AGENT_HOME, config.json, endpoint selection and its overrides, agent configs.
papaya_agent_client/context.py Durable context: fetch, hash cache, compose, and the SessionStart, PreCompact and UserPromptSubmit hooks.
papaya_agent_client/hooks.py The Stop, PostToolUse and SessionEnd hooks, the renew stamp, the hook log, and hooks doctor.
papaya_agent_client/guard.py The PreToolUse hook: write-boundary orchestration and agent-rule evaluation.
papaya_agent_client/write_boundary.py Resolved-root write containment for Claude file and shell tools, plus denial receipts.
papaya_agent_client/listener.py The event loop: pull, playbook, scope filter, reserve, renew, release, re-bid, cursor.
papaya_agent_client/supervisor.py listen --supervised: the JSON Lines protocol, the approval gate and the status phases. The contract is docs/supervised-protocol.md.
papaya_agent_client/headless.py connect --access-token-stdin and login --token-stdin: connecting with no browser.
papaya_agent_client/working_directory.py Where jobs run: validating --working-directory and keeping each job's directory inside it.
papaya_agent_client/runtime_directory.py Recognising a Papaya Agent Runtime checkout, asking it what it supports, and handing listen over to ./bin/ppy serve.
papaya_agent_client/playbook.py The act / acknowledge / ignore decision and the default table.
papaya_agent_client/command_runner.py Running a harness command: token substitution, streaming, timeout, cancellation.
papaya_agent_client/passthrough.py mcp passthrough: the proxy of the hosted MCP server.
papaya_agent_client/mcp_server.py The legacy hand-built proxy and the print-config / doctor / persona-prompt helpers.
papaya_agent_client/harness_mcp.py What an unattended job's harness loads: mcp runner-config, Papaya-entry detection, and the Claude Code and Codex job configs.
papaya_agent_client/hand_back.py hand-back: giving a work item this machine keeps back to Papaya.
papaya_agent_client/harness_conflicts.py harness-conflicts: Papaya MCP entries in the person's Claude Code, Codex and Cursor configs, their identities, and safe removal.
papaya_agent_client/mcp_identity.py Stable agent_ref slugs and the derived connect identity.
papaya_agent_client/ws_client.py The WebSocket carrying the doorbell and the legacy job notification.
papaya_agent_client/plugin/ The plugin, shipped as package data: .mcp.json, hooks/, skills/, PAPAYA.md, and the Claude, Cursor and Codex manifests.
scripts/ The bundled runners papaya-claude-runner.sh and papaya-codex-runner.sh, one per listen --harness. Both are force-included in the wheel.
npm/ The papaya-agent shim: bin/, lib/ (uv bootstrap, checksum, targets, source spec), test/.
docs/supervised-protocol.md The supervised-mode protocol: every message, the version, and the approval semantics.
tests/ test_connect.py (connect, the plugin, the hooks, the doc rules), test_listener.py, test_supervised.py, test_headless.py, test_working_directory.py, test_endpoints_and_home.py, test_passthrough.py, test_runner_isolation.py (job MCP isolation, decline file, identity guards), test_harness_conflicts.py, test_hand_back.py (hand back, not_routed_here), test_runtime_directory.py (runtime checkouts and the hand-off), test_client_runtime.py (the legacy job path), test_version_pins.py.

Local setup

uv sync
uv run papaya-agent --help

Every command in this README works from a source checkout with uv run in front of it. To point the client at a Papaya running on your machine, set PAPAYA_ENV=local (or the older LOCAL_PAPAYA=true), for example PAPAYA_ENV=local uv run papaya-agent connect; PAPAYA_ENV=staging targets staging. With none of the environment variables set the client talks to production, https://api.trypapaya.ai over HTTP and wss://api.trypapaya.ai for the socket. Endpoints are re-derived from the environment on every load and rewritten into config.json on every save, so switching is just the variable. Add PAPAYA_AGENT_HOME=/tmp/some-dir to try something without touching your real ~/.papaya-agent.

Running the tests

uv run pytest -q                          # the Python suite
node --test test/*.test.js                # the npm shim, run from npm/
claude plugin validate papaya_agent_client/plugin   # the plugin manifests

CI runs the first two on every pull request, against Python 3.11 and 3.13 and Node 18 and 22. Some tests keep this repository honest rather than testing behaviour, and they are the ones most likely to fail on a documentation change. In tests/test_connect.py: test_plugin_and_docs_avoid_em_dashes forbids an em dash anywhere in the plugin, this README or docs/supervised-protocol.md; test_docs_never_tell_anyone_to_export_a_token forbids any plugin file or part of this README telling somebody to put a token in a shell profile; and test_version_is_pinned_across_pyproject_banner_and_manifests requires pyproject.toml, the CLI banner and the three plugin manifests to carry one version. tests/test_version_pins.py requires npm/package.json to match pyproject.toml, forbids a version literal in the shim's source, checks uv is pinned once and its download checksum verified, and checks the release workflow gates on the version and publishes with no stored token.

Working on the plugin

claude --plugin-dir papaya_agent_client/plugin loads the plugin for one session without installing it. The hooks shell out to papaya-agent by name, so in a source checkout point PAPAYA_AGENT_BIN at a wrapper that runs uv run papaya-agent, or install the package with uv tool install .. papaya-agent hooks doctor shows which scripts are registered and whether each is present and executable, and ~/.papaya-agent/hook.log is where a fail-soft hook records what went wrong.

Bumping the version

The version appears in seven places and tests enforce that they agree: pyproject.toml (project.version), npm/package.json (version), _BANNER in papaya_agent_client/__main__.py, plugin.json in each of papaya_agent_client/plugin/.claude-plugin/, .cursor-plugin/ and .codex-plugin/, and the version literal asserted in tests/test_connect.py. The git tag is the eighth, and the release workflow checks it against the first two.

Releasing

The Python package and the npm shim are one release with one version number. .github/workflows/release.yml runs on a v* tag and refuses to publish anything unless the tag, pyproject.toml and npm/package.json all name the same version, and unless both test suites pass.

# 1. bump every version listed above
uv run pytest -q
node --test npm/test/*.test.js

# 2. tag and push
git tag v0.16.0
git push origin v0.16.0

There are no registry tokens and no repository secrets. Both registries use trusted publishing: GitHub mints a short-lived OIDC token for the workflow run, the registry checks that the run came from this repository, this workflow file and the named GitHub environment, and every upload carries a provenance attestation the registry publishes beside it. The publish jobs do nothing but publish, hold only the id-token: write permission they need, and consume artifacts built by a job with no publishing rights.

Setting a trusted publisher up is a one-time task for a registry owner. On PyPI, under Add a new pending publisher: project papaya-agent-client, owner Papaya-HQ, repository papaya-agent-client, workflow release.yml, environment pypi. A pending publisher lets the first release create the project; afterwards it lives under the project's own Publishing settings. On npm, from the package page under Settings, Trusted Publisher: GitHub Actions, organization Papaya-HQ, repository papaya-agent-client, workflow release.yml, environment npm. npm only lets an existing package name a trusted publisher, so the very first papaya-agent release is one npm publish --access public from a maintainer's own machine in npm/, with interactive two-factor auth and no token stored anywhere; every tag after that publishes from Actions.

Both jobs run inside GitHub environments named pypi and npm. Creating them under Settings, Environments is optional (a job creates a missing one on first use), but a required reviewer on either one turns a tag push into a release that a person has to approve.

The npm shim is plain CommonJS with no build step and no runtime dependencies. npm/lib/uv-version.json is the single pinned uv version it downloads when a machine has none; bumping it is a one-line change, and the download is checksum verified against Astral's .sha256 sidecar every time.

History

Two planning documents from before this client shipped are kept under docs/history/, and describe intentions rather than the current client: the MCP plan and the workspaces plan.

Release files for papaya-agent-client 0.16.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for papaya-agent-client 0.16.0
File Size Uploaded
papaya_agent_client-0.16.0.tar.gz 412.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for papaya-agent-client 0.16.0
File Interpreter ABI Platform
papaya_agent_client-0.16.0-py3-none-any.whl Python 3 none any Details

Total release size: 617.1 kB

Release files / papaya_agent_client-0.16.0.tar.gz

Download URL papaya_agent_client-0.16.0.tar.gz
Size 412.2 kB
Tags Source
SHA-256 checksum
How to use checksums
c463b357f1ae57bf0119433a63761dbd8ce2454306e1ac2528f501216f25827d
BLAKE2b-256 checksum
How to use checksums
68d66603fcc1ab21fbb73a0d51558506ff1c44142bf14edc38a1e4cb8279e867
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.

Transparency log

Release files / papaya_agent_client-0.16.0-py3-none-any.whl

Download URL papaya_agent_client-0.16.0-py3-none-any.whl
Size 204.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
60f3a49274d11e7f79126cafb109ca4fae233061f937d9d199b283cfff216886
BLAKE2b-256 checksum
How to use checksums
b8a5cc7e11c4b5a9f311f8ba25cdd2f839d96da5d50808da358a56bb6aefce2e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.

Transparency log

Release history Release notifications | RSS feed

0.17.0

2 release files

0.16.1

2 release files

This release

0.16.0 This release

2 release files

0.15.1

2 release files

0.15.0

2 release files

0.14.0

2 release files

0.13.1

2 release files

0.13.0

2 release files

0.12.0

2 release files

0.11.0

2 release files

0.10.3

2 release files

0.10.2

2 release files

0.10.1

2 release files

0.10.0

2 release files

0.9.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page