Skip to main content

Papaya Agent Client

papaya-agent connects local agents to Papaya. The same client supports two workflows:

  • Papaya job dispatch: Papaya sends a job to this machine, the client runs your configured local command, and the result is posted back to Papaya.
  • MCP capabilities: Claude, Codex, or another MCP host can call Papaya tools as one or more configured Papaya agents.

The published package name is papaya-agent-client. The command users run is always papaya-agent.

Install

npx papaya-agent connect

That is the whole setup. The npm package papaya-agent is a small shim around the real client, which is and stays the Python package papaya-agent-client: it finds uv on your PATH or downloads a pinned, checksum verified copy into its own directory, then runs papaya-agent through it. After a successful connect it installs the client properly (uv tool install) so papaya-agent is on your PATH for good.

connect opens your browser, you click Approve, the terminal asks which agent to connect as, and Claude Code has the Papaya tools next time you open it. Nothing is copied, pasted, or exported.

If you would rather manage the Python package yourself:

uv tool install papaya-agent-client
papaya-agent connect

Either way, papaya-agent has to stay on your PATH afterwards: the Claude Code plugin runs it both for its hooks and for the papaya MCP server itself. papaya-agent hooks doctor tells you whether it is there.

Two environment variables steer the npm shim, and neither is needed normally: PAPAYA_UV points it at a uv binary of your choosing, and PAPAYA_AGENT_SOURCE replaces the package spec it installs from (a local path, a git URL, or any other spec uv accepts).

For local development from this repository:

uv sync
uv run papaya-agent --help

The examples below use uv run papaya-agent when they are meant for a source checkout. If you installed the package, drop uv run.

Command Overview

Command Purpose
papaya-agent Print help. It does not start job listening.
papaya-agent login Authenticate, save the user session, and configure local agent connections.
papaya-agent listen Start the event listener (falls back to the legacy dispatched-job listener when there is no connect connection).
papaya-agent workspaces List workspace IDs, slugs, and names for MCP/config setup.
papaya-agent connect One-step harness setup: sign in through the browser, pick the agent here, mint a scoped token, verify it, and install or emit Claude Code, Cursor, or Codex config.
papaya-agent context Print the connected agent's durable context (persona, rules, memories); used by the plugin hooks.
papaya-agent hooks stop Claude Code Stop/SessionEnd hook: renew this session's subject reservations, or release them at session end.
papaya-agent hooks doctor Show which Claude Code hooks are registered and the tail of ~/.papaya-agent/hook.log.
papaya-agent guard Claude Code PreToolUse hook: enforce the agent's checkable rules. Exit 2 denies.
papaya-agent agents add Add or rotate local agent connections.
papaya-agent agents edit Edit local command settings for a configured agent.
papaya-agent agents remove Remove local agent configs.
papaya-agent logout Clear the saved user session and local agent configs.
papaya-agent mcp passthrough The stdio MCP server the harnesses run: a proxy of the hosted Papaya server that reads the token file itself.
papaya-agent mcp ... Run or print MCP server configuration.

Endpoints

By default, the client uses production and does not prompt users for URLs:

HTTP: https://api.trypapaya.ai
WS:   wss://api.trypapaya.ai

For local Papaya development, set:

export LOCAL_PAPAYA=true

That switches both HTTP and WebSocket traffic to localhost:

HTTP: http://localhost:5173
WS:   ws://localhost:5173

First-Time Setup

Run:

uv run papaya-agent login

The setup flow asks for your Papaya email and password, then shows the Papaya agents this account can manage. Your password is never stored. For each selected agent, the client asks for:

  • A display name for this local connection.
  • The command to run when Papaya dispatches a job.
  • The working directory, allowed working directories, and timeout.

Endpoint selection is automatic. Remote agent-client connections are created only after the full setup flow is complete. The saved config lives at ~/.papaya-agent/config.json with 0600 permissions and includes the saved user refresh token plus one scoped pagc_... token per configured agent.

login only authenticates and configures local connections. It does not open WebSockets, drain queued jobs, or start listening for dispatched jobs.

Connect A Coding Harness

papaya-agent connect is the fast path for Claude Code, Cursor, or Codex. It signs you in through the browser, lets you pick the agent here in the terminal, verifies the minted pagc_... connection token with GET /api/v1/whoami, and sets the harness up:

uv run papaya-agent connect                      # Claude Code (default)
uv run papaya-agent connect --harness cursor
uv run papaya-agent connect --harness codex
uv run papaya-agent connect --name "Laptop"

The browser flow (CONTRACT §11.3) is a loopback redirect with PKCE. connect binds http://127.0.0.1:<ephemeral port>/callback, asks the backend where its sign-in page is, opens your browser there, and waits up to five minutes:

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.

The browser authenticates you, and nothing else: the workspace and the agent are chosen in the terminal afterwards, from the same lists --workspace and --agent filter. One of either is auto-picked with a line saying which; more than one gets a numbered prompt. The link is printed on its own line as well as opened, so a browser on another machine works too (--no-browser skips the open). --name sets the connection name (the machine's hostname otherwise).

If you deny the request in the browser, 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 plugin installed and one last line:

Connected as Engineering Agent in Papaya HQ. Open Claude Code in any repository, the papaya tools are ready.

Flags:

  • --device signs in with a short code approved in the Papaya app instead (CONTRACT §6), which is what to use over SSH or on a headless machine. See Device-code sign-in. A backend with no /auth/cli/start route yet falls back to it automatically, with a one-line notice, and so does a machine that cannot bind a loopback port.
  • --password signs in with email and password instead. A backend with neither browser nor device sign-in falls back to this automatically.
  • --no-install skips the Claude Code plugin install, which connect otherwise does for you. Cursor and Codex still wait for --install, which merges the papaya server into ~/.cursor/mcp.json or runs codex mcp add.
  • --print prints only the mcpServers JSON for that harness on stdout (status lines go to stderr), and installs nothing.
  • --via-proxy and --read-only emit the legacy subset proxy instead; see Hosted server or local proxy.

Every harness gets the same papaya server: papaya-agent mcp passthrough over stdio (CONTRACT §11.4), a proxy of the hosted Papaya MCP server at https://api.trypapaya.ai/api/v1/mcp/. It reads the token out of ~/.papaya-agent/connect-token (0600) itself, so no environment variable has to be set and there is nothing to add to a shell profile. The token lives only in ~/.papaya-agent/connect-token and ~/.papaya-agent/config.json (both 0600); connect never writes it into .mcp.json, settings.json, ~/.cursor/mcp.json, or any other shared file.

{
  "mcpServers": {
    "papaya": {
      "type": "stdio",
      "command": "papaya-agent",
      "args": ["mcp", "passthrough"]
    }
  }
}

Because the harness runs the CLI, papaya-agent must be on the PATH the harness starts with. papaya-agent hooks doctor reports whether it is, and the plugin's own entry honours $PAPAYA_AGENT_BIN when it is not.

The pass-through connects once before it serves, which does two jobs: a token the hosted server rejects fails there, on your terminal, with Run papaya-agent connect on stderr and exit 1, and the remote's initialize instructions (your agent's per-connection context) are copied onto the proxy so the model still sees them. Tools, resources and prompts are forwarded dynamically, so a tool Papaya adds server-side appears without a client release. --agent <ref> serves a configured agent's stored token instead of the connect token.

Device-code sign-in

papaya-agent connect --device prints a link and a short code, then waits:

Open https://app.trypapaya.ai/device?user_code=ABCD-EFGH and approve code ABCD-EFGH
https://app.trypapaya.ai/device
ABCD-EFGH
Waiting for approval in Papaya...

Open that page, sign in, and choose the workspace and agent there; the CLI polls until you approve and then stores the token. The plain link and the code are on their own lines so they are easy to copy, and connect also tries to open the browser unless --no-browser. Polling honours the server's interval, backs off when the server says slow_down, and gives up when the code expires; an expired code, a denied request, or a code that was already used prints one line and exits 1.

Hosted server or local proxy

Two ways to give a harness the papaya MCP server, and the default is the right one almost always:

Mode Command When to use it
Pass-through (default) papaya-agent connect Normal setup. The full hosted tool set, proxied over stdio, with the token read from ~/.papaya-agent/.
Legacy local proxy papaya-agent connect --via-proxy You need acting_as on every write result, so the harness can see which Papaya agent it just spoke as.
Legacy local proxy, read-only papaya-agent connect --via-proxy --read-only The session must be able to read Papaya but must not be able to write to it. Write tools are not exposed at all.

--via-proxy is a documented legacy mode: it is the hand-built subset proxy, not the pass-through, and connect no longer emits it by default. It runs a different stdio command:

{
  "mcpServers": {
    "papaya": {
      "type": "stdio",
      "command": "papaya-agent",
      "args": ["mcp", "--agent", "scout", "--read-only"]
    }
  }
}

Claude Code and Codex also get a claude mcp add / codex mcp add line (which --install runs for you); Cursor gets the entry merged into ~/.cursor/mcp.json. --read-only only applies together with --via-proxy, because read-only enforcement lives in the proxy. The proxy resolves its agent from what connect stored, so papaya-agent mcp --agent <ref> works on a machine that only ever ran connect, and it reads the token out of ~/.papaya-agent/connect-token itself, like the pass-through does. The trade-off is the tool set, which is a hand-maintained subset rather than whatever the hosted server publishes (see Add MCP Capabilities).

The bundled Claude Code plugin

The package ships a Claude Code plugin at papaya_agent_client/plugin/ (python -c "from papaya_agent_client.connect import plugin_root; print(plugin_root())" prints the installed path). It contains:

  • .claude-plugin/plugin.json and a local marketplace.json, so claude plugin marketplace add <plugin_root> then claude plugin install papaya@papaya-agent-client installs it. For one session, claude --plugin-dir <plugin_root> works without installing.
  • .mcp.json: the papaya server, which runs ${PAPAYA_AGENT_BIN:-papaya-agent} mcp passthrough over stdio. No URL and no token appear in it, because the pass-through reads both from ~/.papaya-agent/ at run time.
  • hooks/hooks.json wiring seven events to five bash scripts (see Claude Code hooks below). Every one of them is fail-soft: if papaya-agent is missing, nothing is connected, or Papaya is unreachable, they exit 0 and change nothing.
  • skills/papaya-connect/SKILL.md: when to run connect, how to verify with whoami, how to read the full context with get_my_agent_context (papaya://me/context), and the memory rules: save_agent_memory for things about the agent itself, propose_memory for the workspace or people.
  • .cursor-plugin/, .codex-plugin/, and PAPAYA.md for Cursor and Codex, which do not run the hooks and instead load PAPAYA.md as instructions.

Installing the plugin is all it takes for the papaya tools to appear: the server is a command, not a URL with a header, so nothing has to be in Claude Code's environment. The one requirement is that papaya-agent resolves on the PATH Claude Code started with (or that $PAPAYA_AGENT_BIN points at it). The hook's $CLAUDE_ENV_FILE export still publishes the token and session id for Bash tool calls inside the session.

Claude Code hooks

An interactive Claude Code session has no wrapper process around it, so the guarantees papaya-agent listen gives a non-interactive run (reserve, work, release) are wired through Claude Code's own hooks instead.

Event Script What it does
SessionStart papaya-session-start.sh Mints this session's reservation session id, appends PAPAYA_AGENT_TOKEN, PAPAYA_WORKSPACE_ID and PAPAYA_SESSION_ID to $CLAUDE_ENV_FILE, injects the agent's durable context as additionalContext, and declares this connection's runtime and capabilities. The injected block ends with the session id and what to pass it to.
PreCompact papaya-precompact.sh Refreshes and re-injects the context so it survives compaction.
UserPromptSubmit papaya-prompt.sh When the bundle's content_hash has moved since this session last saw it, injects a short header plus the new context. Silent otherwise, which is almost every prompt.
PreToolUse papaya-guard.sh Enforces the checkable part of the agent's rules before a Papaya MCP tool runs. Exit 2 blocks the call and tells the model why.
PostToolUse papaya-post-tool.sh Renews the same reservations in the middle of a long turn, at most once every 180 seconds.
Stop papaya-stop.sh Stop Renews every subject reservation this session holds, at the TTL the server granted.
SessionEnd papaya-stop.sh SessionEnd Releases them.

Stop and SessionEnd are not the same event, and the difference is the reason Stop does not release. Claude Code fires Stop whenever the model finishes responding, which includes pausing to ask the person a question in the middle of a task; 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 actually over, is the release. A renew that comes back 409 means the lease was lost to somebody else: the hook says so and releases nothing, because the subject is no longer ours to release. A renew that comes back renewed: false means the lease was lost too (a person released it, or it expired) and the renew has just re-acquired it; the hook releases it again and reports it, so a session never re-takes what somebody handed back. All three renew and release events touch only reservations whose holder.session_id is this session's, so a listener sharing the connection keeps its own.

Stop alone is not enough for a long turn: it fires when the model stops talking, and one tool-heavy turn can run past a 15 minute lease without ever reaching it. PostToolUse renews mid-turn, which is far too often to call Papaya every time, so the bash hook is gated on a stamp file: ~/.papaya-agent/renew-<claude session id>.stamp (0600) is rewritten every time the CLI renews, and while it is younger than 180 seconds the hook exits without starting Python at all. A session holding nothing still stamps, so an idle session costs at most one call every 180 seconds; SessionEnd deletes the stamp.

A lost lease is the one thing these hooks tell the model rather than the log. Claude Code shows the model nothing from a hook that exits 0, so a session whose subject was taken away would carry on working it. Instead Stop and PostToolUse print one line of {"decision": "block", "reason": "..."} on stdout when a renew came back 409 or renewed: false; the reason names each subject, says how it was lost, and tells the model to stop, tell the person, and post, edit or push nothing more for it. SessionEnd never blocks (the session is over and there is no model left to tell), a payload with stop_hook_active set is only logged (blocking a turn that a block already resumed would loop), and a renew that succeeds prints nothing at all.

The session hooks are tied together by Claude Code's own session_id, which arrives on each hook's stdin. SessionStart records {session_id, content_hash, started_at} under that key in ~/.papaya-agent/context-cache/<connection_id>.json, and the later hooks read it back. The rest of the hook payload is barely read: tool_name and tool_input for PreToolUse, and stop_hook_active for the two renew events.

Rule enforcement is deliberately narrow. Only a rule with an enforce object is mechanical:

  • {"deny_tool": "<glob>"} blocks the tool outright.
  • {"require_substring": {"tool": "<glob>", "substring": "<str>"}} blocks it unless the serialised tool input contains that substring.

The glob is matched against the tool name with the mcp__papaya__ prefix stripped, so rules are written as post_message, not mcp__papaya__post_message. The PreToolUse matcher is mcp__papaya__.*, so rules never speak for the harness's own file or shell tools. Free-text rules with enforce: null are for the model to follow, not for the hook to check.

Enforcement fails open. No cache, no rules, an unreadable payload or any exception at all allows the call and appends a line to ~/.papaya-agent/hook.log. A rule that quietly stops being enforced is a bad day; a hook that bricks an interactive session is a worse one.

uv run papaya-agent hooks doctor   # what is registered, and the last 20 log lines

Cursor and Codex do not run hooks. Those harnesses rely on papaya-agent listen to renew and release subjects when a listener is driving them, and otherwise on lease expiry or an explicit release_subject: Codex has no lifecycle hooks at all, so nothing ends its leases for it.

What a connection declares about itself

Every connection tells Papaya what is on the other end of it, through PATCH /api/v1/workspaces/{ws}/polyweave-agents/me/connection. That is how the app can tell an interactive session from a listener, and how the backend knows whether to enforce the agent's rules itself.

Runtime runtime_kind Capabilities Who enforces enforce rules
Claude Code (the plugin, at SessionStart) claude-code heartbeat: true, interception: "advisory" the PreToolUse hook, locally
connect --harness claude claude-code heartbeat: true, interception: "advisory" the PreToolUse hook, locally
connect --harness codex codex heartbeat: false, interception: "enforcing" Papaya, server side
connect --harness cursor cursor heartbeat: false, interception: "enforcing" Papaya, server side

For an enforcing connection Papaya evaluates the agent's enforce rules before it runs a tool call that arrives on that connection's token, and a blocked call comes back as the tool error Blocked by agent rule [<id>]: <text>. An advisory connection is checked by nothing on the server, because the PreToolUse hook has already checked it.

SessionStart declares at most once per connection per 24 hours (a declared_at stamp in the per-connection cache) and connect declares once per run. Both are fail-soft: SessionStart logs and carries on, connect prints one warning line and still finishes.

Durable context from the CLI

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

context reads the connection recorded by the latest connect, calls GET /api/v1/workspaces/{ws}/polyweave-agents/me/context with the agent token (never with include_sensitive=true), and caches the result per connection at ~/.papaya-agent/context-cache/<connection_id>.json (0600, directory 0700) for 60 seconds keyed by the backend content_hash. An unchanged hash reuses the cached rendering; a changed hash overwrites it; if Papaya is unreachable the last cached block is served.

Known gap: the backend doc docs/agent-api/papaya-agent-client.md predates connect and context and needs an update.

Workspace IDs

List workspaces for the saved user session:

uv run papaya-agent workspaces

Default output is a column-aligned table:

ID                                    SLUG          NAME
8f3c4b9d-7a21-4c8e-9b3f-2a1c5d4e6f70  acme          Acme Inc
b2a1f5e8-c4d6-4831-9b87-3a2d1c5e8f90  side-project  Side Project

Use JSON-lines output for scripts:

uv run papaya-agent workspaces --json
{"id":"8f3c4b9d-7a21-4c8e-9b3f-2a1c5d4e6f70","slug":"acme","name":"Acme Inc"}

Use quiet output when only IDs are needed:

uv run papaya-agent workspaces -q

The workspace endpoint does not currently return the caller's workspace role, so role is intentionally not shown.

Listen For Events

uv run papaya-agent listen

When this machine has a connection from papaya-agent connect, listen runs the connected-agent event loop. 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 papaya.events_available doorbell that says "go pull", so the listener reacts immediately but never depends on the socket being up. The same process still serves legacy dispatched jobs (see Listen For Dispatched Jobs); if there is no connect connection, listen falls back to the legacy loop unchanged.

Flag Default Meaning
--run '<command>' the packaged papaya-claude-runner.sh Harness command; {prompt} is replaced with the generated instruction.
--poll-interval N 20 (env PAPAYA_POLL_INTERVAL) Seconds between event pulls.
--max-concurrent N 1 Subjects worked on at once.
--runtime-kind KIND custom Label reported to Papaya (claude-code, codex, …).
--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.

Claude Code and Codex are two command strings, not two code paths:

# Claude Code (the default)
uv run papaya-agent listen --runtime-kind claude-code

# Codex; see scripts/papaya-codex-runner.sh
uv run papaya-agent listen --runtime-kind codex \
  --run './scripts/papaya-codex-runner.sh {prompt}'

At startup the listener reports its capabilities to Papaya (delivery, heartbeat, resume, interception, max_concurrent_subjects). That call is fail-soft: if it does not go through, the listener logs it and carries on.

The packaged Claude runner reads one environment variable of its own:

Variable Default Meaning
PAPAYA_CLAUDE_PERMISSION_MODE bypassPermissions The --permission-mode the runner passes to claude. Set it to acceptEdits, plan, or default to run the listener's sessions under a stricter mode.
PAPAYA_CLAUDE_PERMISSION_MODE=acceptEdits uv run papaya-agent listen

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".
uv run papaya-agent listen --match 'work_item.*"kind": "bug"'

# The backend machine: anything except the frontend.
uv run 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: the cursor advances, nothing is reserved, and one line is logged.

[listen] event=4512 not mine (--match/--exclude): work_item.assigned work_item:5d0c...

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:

uv run papaya-agent listen --explain-match ./envelope.json --match '"kind": "bug"'
# Canonical: work_item.assigned work_item:5d0c... {"note": null, "work_item": {"kind": "bug", ...}}
# Verdict: run

It exits 0 when the event would be worked on this machine and 1 when it would not, so it fits in a script. A malformed pattern is rejected before anything connects.

The Playbook

Each event kind maps to act, acknowledge, or ignore. The mapping comes from the agent's resolved event playbook on me/context, edited in the Papaya app and merged by the server over the defaults below. No model call is made to decide.

Event kind Reservable Default action
mention.you yes act
work_item.assigned yes act
work_item.comment yes act
dm.received yes act
approval.waiting yes act
work_item.status_changed no acknowledge
subject.reserved no ignore
subject.released no ignore

acknowledge advances the cursor and does nothing else. ignore does the same and does not even log it as interesting. An unknown event kind is ignored, and act on a non-reservable event is downgraded to acknowledge, because the listener never reserves a subject the server did not offer a lease on.

Subject Reservations

A subject (work_item:<uuid>, thread:<uuid>, dm:<uuid>, proposal:<uuid>) is the unit of work. Only one session works a subject at a time:

  • Before running, the listener reserves the subject. If another session already holds it, the holder is logged, the event is skipped, and the cursor moves on. No retry storm, no jitter.
  • While the harness runs, the lease is renewed every granted_ttl_seconds / 3. Network failures are retried on the next tick; the server-side TTL is the safety net, so a flaky network never kills good work.
  • If a renewal comes back "held by someone else" (409), the lease was lost: the harness is killed and the subject is not released, because this session no longer owns it.
  • If a renewal comes back renewed: false, the lease was also lost, and this call has just taken the subject again. The harness is killed and the subject is released, because the accidental re-acquire made this session the holder.
  • On any exit (clean, crash, or SIGTERM) the subject is released.

Losing a race is not the end of it. A subject this listener wanted but lost (the reserve came back 409) is remembered along 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
Release reason What it means Re-bid
expired The holder's lease ran out. yes
revoked The holder's connection was revoked. yes
declined The holder said the work was not theirs. yes
holder The holder finished. no
human A person took the subject away. no

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). A harness command that exits with status 75 tells the listener exactly that: the subject is released as declined, which is the one release reason that invites peers to re-bid, and the event is not run again here.

[listen] harness declined work_item:5d0c...

Every other exit status, including a crash, releases the subject as usual. The generated prompt tells the model about the convention, so a harness that just runs a model gets this for free.

A person releasing a subject stops the holder, at its next renew tick. Releasing a reservation from the Papaya app leaves nobody holding the subject, and reserve_subject is acquire-or-extend, so the holder's next renew would otherwise succeed by quietly acquiring the subject a second time and carry on working on something a human had explicitly taken away. Reading renewed in the reply is what closes that gap. The delay is bounded by the renew cadence, so a release lands within granted_ttl_seconds / 3 (at most a few minutes at the default TTL). The same rule applies to an interactive Claude Code session, where the Stop hook is the renew tick: it hands back what it re-took and reports the lost lease on stderr.

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. Delivery is at-least-once and the server is idempotent. After each handled batch the listener also calls the ack endpoint, so a fresh machine resumes server-side. A missing or corrupt cursor file reads as 0 rather than refusing to start.

When every worker slot is busy, the remaining events are simply left in the stream and re-read on the next pull.

What The Harness Receives

The command is run 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; capped at 2,000 characters). The prompt always ends with the same three lines, and truncation trims the free text rather than any of them:

If this event is not yours to act on (wrong repository, wrong machine, out of scope), exit with status 75 and do nothing 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.

Memory write-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>-<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_WORKING_DIRECTORY The resolved working directory (used by the Codex example for --cd).

The existing PAPAYA_RUNNER_SESSION_* continuity variables are still set. 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.

scripts/papaya-codex-runner.sh is an example, not a supported integration: it uses only codex exec and --cd from Codex's public documentation.

Listen For Dispatched Jobs

The legacy dispatched-job path is unchanged. Use it when jobs should originate in Papaya and execute on this machine. The client opens one WebSocket per configured agent, drains queued jobs on startup and reconnect, runs the configured command, and posts the result back to Papaya.

If no local agents are configured yet, listen uses the saved user session to run the same agent setup flow as login before opening WebSockets.

Job Debug Logs

For each claimed job, the listener writes the full prompt and directory resolution metadata to:

~/.papaya-agent/job-logs/<job-id>/

The console prints the prompt length, a short prompt preview, the prompt file path, the command template, and the directory hint/configuration used to resolve the working directory. To also print the full prompt to the console, start the listener with:

PAPAYA_AGENT_LOG_FULL_PROMPT=true uv run papaya-agent listen

Command Templates

Commands are stored as argument arrays and are not run through a shell. If any argument contains {prompt}, the job prompt is substituted into that argument. Otherwise the prompt is written to stdin. Command arguments may also use {job_id}, {runner_session_key}, {runner_session_id}, {work_item_id}, and {runner_session_file}.

Examples:

["claude", "-p", "{prompt}", "--enable-auto-mode"]
["codex", "run", "{prompt}"]
["./agent-runner.sh"]

Relative executables such as ./agent-runner.sh are resolved from the agent's configured working directory. Job working-directory hints must be inside the agent's allowed working-directory list.

Runner Session Continuity

When Papaya dispatches follow-up work from the same WorkItem, the claimed job can include a prior runner session id. The client passes continuity context to the configured command through environment variables:

PAPAYA_EXTERNAL_JOB_ID
PAPAYA_JOB_ID
PAPAYA_RUNNER_SESSION_KEY
PAPAYA_RUNNER_SESSION_ID
PAPAYA_WORK_ITEM_ID
PAPAYA_RUNNER_SESSION_FILE

Wrapper scripts should resume the external agent session when PAPAYA_RUNNER_SESSION_ID is present. After the command finishes, write the current session id to PAPAYA_RUNNER_SESSION_FILE or print a line like:

PAPAYA_RUNNER_SESSION_ID=<session-id>

The client removes that marker from the posted output and sends the session id back to Papaya so the next WorkItem dispatch can resume the same session.

This repository includes a Claude wrapper that resumes a prior session when Papaya provides one and exposes the Papaya MCP tools to the dispatched Claude session, scoped to the same agent that received the job:

["/path/to/papaya-agent-client/scripts/papaya-claude-runner.sh", "scout"]

The single positional argument is the Papaya agent_ref this command runs as (use the same ref configured for the agent in ~/.papaya-agent/config.json). The wrapper reads the prompt from stdin, generates a temporary MCP config with papaya-agent mcp print-config --agent <agent-ref>, and calls claude -p ... --enable-auto-mode --mcp-config <tmpfile>. It adds --resume "$PAPAYA_RUNNER_SESSION_ID" when a prior session exists. For the first dispatch from a WorkItem, it uses --session-id "$PAPAYA_WORK_ITEM_ID" when that value is a UUID and writes the session id to PAPAYA_RUNNER_SESSION_FILE so Papaya can attach it to later follow-up jobs. It removes the temp config on exit.

If papaya-agent is not on PATH (for example, you run from a uv checkout), set PAPAYA_AGENT_BIN to an absolute path or wrapper that invokes it. The inner Claude session can then call papaya_list_channels, papaya_post_message, etc. as that pinned agent without needing agent_ref arguments.

Manage Local Agents

Add more Papaya agents or rotate an existing local connection:

uv run papaya-agent agents add

Edit the local command settings for a configured agent:

uv run papaya-agent agents edit

Remove local agent configs:

uv run papaya-agent agents remove

Log out and clear cached agents:

uv run papaya-agent logout

Add MCP Capabilities

MCP mode lets an external agent use Papaya without waiting for a dispatched job. It uses configured agent tokens from setup, or the saved user session for connected-service user mode.

Available MCP tools include:

  • papaya_whoami
  • papaya_list_channels
  • papaya_read_channel_messages
  • papaya_post_message
  • papaya_list_agents in shared multi-agent mode
  • Connected-service tools such as github__..., linear__..., google__..., and trello__... when those accounts are connected in Papaya.

All MCP modes are read/write by default. Add --read-only when an MCP host should inspect Papaya state but not post messages.

List the configured Papaya agent refs first:

uv run papaya-agent mcp list-agents

The agent that papaya-agent connect pinned is always in that list, even on a machine that has no agents block in ~/.papaya-agent/config.json: its identity comes from the connect record and its token from ~/.papaya-agent/connect-token. That is what makes papaya-agent connect --via-proxy work with nothing else set up. The derived entry is built on each load and never written back into config.json.

Choose An MCP Mode

Mode Command When to use it
Single agent papaya-agent mcp --agent scout One Claude/Codex assistant should act as one Papaya agent. This is the simplest and least ambiguous mode because tools do not need an agent_ref argument.
Single agent, read-only papaya-agent mcp --agent scout --read-only The external assistant should read channels and messages but should not be able to post.
Multi-pinned papaya-agent mcp print-config --multi-pinned Your MCP host can register multiple named servers and you want one clear server identity per Papaya agent. Each server is still pinned, so tool calls stay simple.
Shared multi-agent papaya-agent mcp --multi-agent One external assistant should see or coordinate across multiple Papaya agents or workspaces. Tool calls require agent_ref, so use a persona prompt when the assistant should stick to one identity.
User connected services papaya-agent mcp --user Expose connected-service tools using your saved Papaya user session instead of a pinned Papaya agent identity. If the account has multiple workspaces, add --workspace <workspace-id>.

Print MCP config JSON for hosts that support config files:

uv run papaya-agent mcp print-config --agent scout
uv run papaya-agent mcp print-config --multi-pinned
uv run papaya-agent mcp print-config --multi-agent
uv run papaya-agent mcp print-config --user
uv run papaya-agent mcp print-config --user --workspace <workspace-id>

For shared multi-agent servers, generate a persona prompt and add it to the external assistant's instructions:

uv run papaya-agent mcp persona-prompt --agent scout

Run diagnostics without printing raw pagc_... tokens:

uv run papaya-agent mcp doctor
uv run papaya-agent mcp doctor --agent scout

Register With Claude Code Or Codex

For an installed papaya-agent command:

# Claude Code, pinned single-agent server
claude mcp add papaya-scout -- papaya-agent mcp --agent scout

# Claude Code, shared multi-agent server
claude mcp add papaya-agents -- papaya-agent mcp --multi-agent

# Claude Code, user connected-service tools
claude mcp add papaya-connect -- papaya-agent mcp --user

# Codex, pinned single-agent server
codex mcp add papaya-scout -- papaya-agent mcp --agent scout

# Codex, shared multi-agent server
codex mcp add papaya-agents -- papaya-agent mcp --multi-agent

# Codex, user connected-service tools
codex mcp add papaya-connect -- papaya-agent mcp --user

For a local source checkout where papaya-agent is not installed globally, replace /path/to/papaya-agent-client with this repository path:

claude mcp add papaya-scout -- uv --directory /path/to/papaya-agent-client run papaya-agent mcp --agent scout
claude mcp add papaya-agents -- uv --directory /path/to/papaya-agent-client run papaya-agent mcp --multi-agent
claude mcp add papaya-connect -- uv --directory /path/to/papaya-agent-client run papaya-agent mcp --user

codex mcp add papaya-scout -- uv --directory /path/to/papaya-agent-client run papaya-agent mcp --agent scout
codex mcp add papaya-agents -- uv --directory /path/to/papaya-agent-client run papaya-agent mcp --multi-agent
codex mcp add papaya-connect -- uv --directory /path/to/papaya-agent-client run papaya-agent mcp --user

Claude defaults to local scope. Add -s user or -s project before the server name if you want a different Claude MCP scope.

Concrete Example

Suppose Papaya has two configured local agents:

  • scout: reads Papaya channels and gathers context.
  • builder: posts implementation updates back into Papaya.

For a focused Claude Code session where Claude should only act as scout, use a pinned server:

papaya-agent mcp list-agents
claude mcp add papaya-scout -- papaya-agent mcp --agent scout

Claude can then call papaya_list_channels, papaya_read_channel_messages, and papaya_post_message as scout without passing agent_ref. This is usually the best setup for one external assistant working under one Papaya identity.

For a Codex session that needs to coordinate both scout and builder, use one shared server:

codex mcp add papaya-agents -- papaya-agent mcp --multi-agent
papaya-agent mcp persona-prompt --agent builder

Add the generated persona prompt to the Codex instructions for sessions that should act as builder. In shared mode, Codex can call papaya_list_agents to see available identities, then pass agent_ref: "scout" or agent_ref: "builder" on channel and message tool calls.

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 the version in pyproject.toml, npm/package.json, the __main__ banner,
#    and the three plugin manifests (tests enforce that they agree)
uv run pytest -q
node --test npm/test/*.test.js

# 2. tag and push
git tag v0.9.0
git push origin v0.9.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:

Registry Where What to enter
PyPI Account → PublishingAdd 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.
npm package page → SettingsTrusted 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 lives in npm/: plain CommonJS, no build step, 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.

Protocol Notes

The backend durable job-loop endpoints use scoped pagc_... connection tokens. Running papaya-agent login or papaya-agent agents add with an existing cached connection id rotates that specific local connection and invalidates its old token. It does not revoke other machines listening for the same Papaya agent.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

papaya_agent_client-0.9.0.tar.gz (258.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

papaya_agent_client-0.9.0-py3-none-any.whl (115.5 kB view details)

Uploaded Python 3

File details

Details for the file papaya_agent_client-0.9.0.tar.gz.

File metadata

  • Download URL: papaya_agent_client-0.9.0.tar.gz
  • Upload date:
  • Size: 258.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for papaya_agent_client-0.9.0.tar.gz
Algorithm Hash digest
SHA256 492ba4177b0d8feecb3443a9fb83ea07c8d06a7afb298f07fee0338663e12796
MD5 0f8bb471b15b327a9c85398c4ca47655
BLAKE2b-256 b6d3725e5c18bb481b8d9eecb0a5057460e14a1c9439610c5ba4d2dc36c49175

See more details on using hashes here.

Provenance

The following attestation bundles were made for papaya_agent_client-0.9.0.tar.gz:

Publisher: release.yml on Papaya-HQ/papaya-agent-client

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file papaya_agent_client-0.9.0-py3-none-any.whl.

File metadata

File hashes

Hashes for papaya_agent_client-0.9.0-py3-none-any.whl
Algorithm Hash digest
SHA256 196e21d2d9d0496accacf6fbc392f5df9084b0eb9411a326c0216a5065112436
MD5 d8b8aad96fb51434fe69ae15416c8150
BLAKE2b-256 bfc34cfc30eec5b631fcca0dae441f573f7cb344c71550571b76ee79b0a8daa1

See more details on using hashes here.

Provenance

The following attestation bundles were made for papaya_agent_client-0.9.0-py3-none-any.whl:

Publisher: release.yml on Papaya-HQ/papaya-agent-client

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.10.3

2 files

0.10.2

2 files

0.10.1

2 files

0.10.0

2 files

This release

0.9.0 This release

2 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