Pearl Connect agent. When a user starts the BYOA agent in Pearl, the middleware runs this binary like any other non-aea agent. It:
- decrypts the agent EOA keystore (
./ethereum_private_key.txt) in memory using the keystore password — read from stdin until EOF with--password-stdin(docker-style, one trailing newline stripped; preferred: argv is world-readable via/proc/<pid>/cmdlineon Linux) or from the legacy--passwordargument — key material never leaves the process; - populates the service's persistent workspace (
STORE_PATH) for either harness: the MCP entry with a fresh bearer token every run (.mcp.jsonfor Claude Code,.codex/config.tomlfor Codex), the context brief (CLAUDE.md, copied toAGENTS.md), the bundled skills (pearl-connectfor signing,connect-polymarketfor prediction markets,connect-stocktokensfor Robinhood Chain Stock Tokens,connect-ponsfor the Pons memecoin launchpad on Robinhood Chain) and the shared modules they import, installed asskills/andlib/under both.claude/and.agents/; - serves on
127.0.0.1:8716:- Pearl SDK contracts:
GET /healthcheckandGET /funds-status.is_healthyturns true only once the workspace is populated — Pearl opens the session the moment it does, so health is a promise the server has to be able to keep - the agent UI at
GET /: the bundled build inconnect/assets/ui, read into memory at boot and served from there. It ships a stand-in page (settings, harness, open a session — everything it shows comes fromGET /settings); see docs/agent-ui.md - Settings:
GET /settings(open) andPATCH /settings(merge-patch of the canonical shape; the keystore password gates theprotectedobject — currently the mode; the whitelist is read-only until its editing semantics are specced — while theharnesspreference needs none) POST /session(origin-gated, no token): opens an agent session in the configured harness (claude_code_desktop→claude://code/new?folder=…,claude_code_cli→claude-cli://open?cwd=…,codex_desktop→codex://threads/new?path=…, see Codex) and answers{launched, harness, requested, error?}—harnessis the one that opened,requestedthe one it aimed for. An optional{"harness": …}body overrides the saved preference for that launch alone, without rewriting what the operator chose- a bearer-authed signing surface:
POST /safe-transaction,POST /sign-and-send,POST /sign-message,GET /wallet - MCP (streamable HTTP) at
/mcpwith toolswallet_info,safe_transaction,send_transaction,preflight_transaction,transaction_status,sign_message,mech_tools,mech_request,mech_result(plus a read-onlysettingstool whenEXPOSE_MODE_TO_AGENTis on).
- Pearl SDK contracts:
The binary opens no session itself: Pearl waits for is_healthy, then calls
POST /session. A launch failure (harness not installed, deep link unhandled)
then reaches the operator's UI as a dismissable error instead of dying in this
process's log. Neither Pearl nor the UI names a harness, so those launches
start at the saved preference — until an operator changes it, only our default
guess — and fall back to the other harnesses rather than leave them with no
session at all. A launch that does name one opens there or not at all:
naming a harness is a choice.
What the session must not inherit is our own packaging. We ship as a
PyInstaller one-file binary, whose bootloader puts its extraction directory at
the front of LD_LIBRARY_PATH — and that directory carries our
libcrypto.so.3, built against an older OpenSSL than a modern distro's. A
session started with it cannot run the system node, so every node-based hook
and MCP server dies: the hooks noisily, the MCP servers by silently never
appearing in the tool list. So the deep link is handed off with
LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, their _ORIG twins and the _PYI_*
variables stripped. Restoring LD_LIBRARY_PATH_ORIG, PyInstaller's usual
advice, is wrong here: under Pearl that variable names the middleware's private
lib dir, carrying the same old libcrypto — the child needs these gone, not
restored. An operator who genuinely needs one of them can set it back for the
session in the workspace's .claude/settings.json or .codex/config.toml,
which we merge into rather than own.
The agent-harness session names the actions; the server signs and broadcasts
them — a single audited choke point, no plaintext secrets on disk. The agent
acts as the service safe: it is the msg.sender contracts see, and approvals,
swaps, stakes, transfers, etc. are all calls it makes. The session never composes
one, though — it names the inner call (safe_transaction, POST /safe-transaction) and the server wraps it in the safe's execTransaction,
threshold-1 pre-validated signature and all. Nothing about the safe — its
address, its ABI, its signature convention — is the session's problem.
Two harnesses open Codex. codex_desktop uses the desktop app's
codex://threads/new link, with mode=codex so an app sitting on its chat
tab switches to Codex rather than opening the thread there; the parameter is
read from the app's code, not documented, so it may change under us. The Codex
CLI registers no URL handler, so codex_cli does what Claude Code's
claude-cli:// handler does for itself: it opens a terminal running codex
in STORE_PATH. On Linux that is $TERMINAL when it names a terminal Connect
knows how to drive, then x-terminal-emulator, then the first known emulator
installed, and codex runs through the operator's login shell when it is a
POSIX one, else bash. On macOS, Terminal.app opens a self-deleting .command
file, which needs no Automation permission. On Windows, Windows Terminal, then
cmd.exe. The CLI cannot pre-fill a prompt, so that session opens empty. A
terminal only opens once codex resolves in that shell (shutil.which on
Windows); otherwise the launch fails as not installed, and a fallback moves
on, instead of flashing a window that cannot find it.
Codex loads a project's .codex/config.toml — our MCP entry with it — only
once the operator trusts the folder, and asks them to the first time a session
opens there. Connect never answers for them and never touches the user's own
Codex config; a session in an untrusted folder has no signer tools, and the
brief tells the agent to say so.
The workspace config turns on sandbox_workspace_write.network_access, unless
the file already sets it: Codex's workspace-write sandbox otherwise blocks
every socket, the signer on localhost included, and the skills' scripts need
the network. It is all or nothing on purpose: Codex's permission profiles can
narrow network access to a domain allowlist, but its private-network guard
blocks loopback, where the signer is, unless allow_local_binding is also set,
and the skills reach operator-configured RPCs and several venue APIs, so the
list would differ per operator. Two differences from Claude Code remain. Every MCP tool call asks
for approval, as Claude Code's do without an allow rule. And there is no
equivalent of the Read deny rule: the token in .codex/config.toml is
gitignored, not kept out of transcripts.
Two rules hold in every mode, and no setting lifts them: the safe may not
delegatecall, and the safe may not call itself (enableModule,
addOwnerWithThreshold, setGuard — the ways a Safe changes what it is).
Both would outlive a switch back to restricted mode, so the guardrail refuses
them regardless of mode; the reasoning lives at the top of connect/guard.py.
On that floor, the signer enforces one of two persistent modes. The modes are
an operator concept only: agent-facing surfaces (the MCP tools, GET /wallet,
the workspace brief and bundled skills, and every guard refusal message)
deliberately never say a mode system exists — a blocked request names only the
rule it violated and that the operator's guardrail settings are where to
change it. Flipping EXPOSE_MODE_TO_AGENT in connect/settings.py restores
the agent-visible mode readouts (mode in wallet_info/GET /wallet and the
settings MCP tool).
- unrestricted (default) — any other well-formed request is signed;
- restricted (operator opt-in) — raw digest signing is off, with one
carve-out: the mech flow recomputes the off-chain request id locally from
inputs it already validated, wraps it into the safe's ERC-1271 SafeMessage
hash (the safe is the off-chain requester of record), and registers exactly
that digest with the guard as a single-use, short-lived allowance before
mech-client asks for the signature (nothing the agent session names can
register one, and a digest the server did not derive — say, from a lying
RPC answering
getRequestId— mismatches and is refused). The only allowed transaction is a safeexecTransactionCALL to a whitelisted address with the gas-refund fields zeroed (a non-zero SafeTxgasPricewould pay a refund out of the safe past the whitelist) — plus one analogous carve-out: before an off-chain request that may auto-deposit, the mech flow pre-authorizes the one safe→balance-tracker payment a 402 top-up would send, single-use, shape-checked (bare native transfer ordeposit(uint256)with no inner value) and capped at the same bound mech-client itself enforces on the shortfall (10× the mech's per-request rate). The MechMarketplace contract per chain (imported from the pinned mech-client) is whitelisted by default — the only contract the safe calls in the on-chain mech flow — so mech requests work out of the box. Balance trackers and payment tokens are deliberately not whitelisted: the whitelist is address-level (any calldata), so a token entry would permit arbitrary transfers, and the safe only calls trackers for prepaid deposits, which restricted mode admits only through the one-shot capped deposit allowance described above.
The agent can ask before it spends: preflight_transaction composes the same
bytes the send would and runs them past the same gate, answering {allowed}
plus the refusal the real call would have raised. Nothing is signed or
broadcast, and no single-use allowance is spent — the reasoning for that is at
the top of connect/guard.py.
Asking is not a new capability: a refused send never reached the chain either,
because the gate runs before signing. What asking avoids is the audit entry, so
a dry run is recorded as checked rather than blocked — an operator can
still see a session probing for what it can get away with, without a request
that was never sent being logged as one the guardrail stopped. The entry
carries the call as it was asked about (probed_target, probed_value,
via_safe) beside the composed transaction it would have become, since on the
safe path the composed target is the safe itself and an allowed probe would
otherwise record nothing about what was probed.
Funding the safe is the operator's job, through Pearl — the agent has no EOA→safe sweep, because it never needed one.
There is a single gate with no bypass: the MCP tools, the HTTP signing
endpoints and the mech request flow all pass the same check. State persists in
pearl-connect.settings.json at STORE_PATH; the security-critical fields
(mode, whitelist) are HMAC'd with a key derived from the agent private key
and verified on every read — an edit by the agent (or anything else without
the key) fails verification and resets them to the defaults — deliberate,
and audited; the reasoning lives in connect/settings.py's module
docstring. The
harness preference is stored alongside without integrity checks and survives
a guardrail reset. It is outside the MAC because it cannot move funds or widen
the guardrail — every harness is only a way to open a coding agent on the
operator's own machine, and editing the field just reorders which one is tried
first. What a tamper can still do is mislead: leave the UI showing a preference
the operator's sessions are not opening in.
The MAC of the last file the server wrote is also pinned
in memory, so replaying an old validly-MAC'd settings file (say, captured
while the mode was unrestricted) fails the same way; only a replay staged
while the server is stopped escapes the pin. Operators change the mode in the agent UI at
http://127.0.0.1:8716/; the change is authenticated by re-decrypting the
keystore with the submitted password, not by the session's bearer token. The
whitelist is not editable through the API yet — a patch replaces it wholesale
across all chains and only its address format can be validated here, so it
stays frozen at the defaults (a whitelist in a patch is a 422) until those
semantics are designed.
Binding to 127.0.0.1 does not make the server unreachable from the web:
any page the user's browser visits can fire requests at localhost, and DNS
rebinding defeats some browser-side protections. Hence: every route that moves
funds or changes the guardrail requires the bearer token (or the keystore
password for the protected settings), Origin headers are validated, only
loopback Host headers are accepted, and no CORS is enabled. Two state-changing
routes are gated on origin locality alone, because the FE that calls them
holds no token: POST /session (spawns an agent session on the operator's
machine) and the harness half of PATCH /settings. Neither can move funds or
widen the guardrail; the deliberate trade is that any local process — including
the agent's own session — can open a session window or change which harness
it opens in. Repeated auth failures are audited to the activity log and
rate-limited (429) so a probed token is loud, not silent. The
token itself is header-only, rotated per run, dies with the process, and the
provisioned workspace ships a .gitignore so it is never committed and, for
Claude Code, Read deny rules on .mcp.json* and .codex/config.toml* so it is
not read into session transcripts.
Out of scope for v1: SSH port forwarding or running on a shared/remote
machine voids the loopback assumption entirely, and same-user local malware
can read .mcp.json directly — the guardrail (not the token) is the defense
that survives those.
The mech_request MCP tool drives mech
requests through mech-client's Signer protocol, so every transaction and
digest passes the guarded choke point. The default off-chain prepaid flow
works in both modes: it signs the request-id digest, which restricted mode
allows through the server-derived single-use allowance described above, and
its auto_deposit top-up (paying the balance tracker from the safe on HTTP
402) is pre-authorized the same way — capped, single-use, armed only for the
request being sent. The flow also needs a mech whose operator published an
endpoint in its on-chain metadata — few have, so mech_tools reports
offchain_capable per mech and a request to one that cannot serve it is
refused before any payment. The on-chain path sends through the
MechMarketplace via the service safe and works for any listed mech.
Each request carries a spending budget, max_payment, checked against the
mech's price before anything is paid. It is denominated in the mech's payment
asset, so its default is 0.1 of that asset — 10^17 base units for native and
OLAS mechs, 10^5 for USDC ones, including Robinhood Chain, where that payment
type is USDG. mech_tools reports the asset as payment_token.
An optional request_context dict is passed through unmodified as a
top-level key of the request metadata, for the tool to read; the bundled skill
documents the keys the predict tools look for. Off-chain it is part of the
document the request-id digest commits to, so that digest is derived over the
very bytes mech-client will hash. On-chain the document goes to IPFS with its
CID in the request event, making the context as public and permanent as the
prompt beside it.
A mech request is paid for before it is answered, so a caller whose response
was lost cannot tell a spent request from an unsent one — and the only way to
find out costs another payment. mech_request therefore takes an optional
caller-chosen request_id; repeating it never sends again, and the report
comes back marked replayed.
What the id covers depends on where the first attempt stopped, because the server can only promise what it can actually know:
- It returned. The replay resumes each watch still outstanding and picks
up whatever landed since — the usual case, since a caller retries precisely
because the first call returned before the mech answered. Ids that resolve
move into
delivery_results; ids whose read failed stay inpending_request_idswith the cause inreplay_errors; ids a directmech_resultcall already collected come back asunrecoverable_request_ids, because that answer was handed to another caller and this server keeps no copy. - It failed before the paying call. The id is released, so a retry is an ordinary first attempt.
- It failed after the paying call was entered. mech-client pays before it watches, so the spend is genuinely unknown. The id is kept and every replay refuses it: sending again is a decision to risk a second payment, and it takes a new id to say so.
Two bounds worth knowing: the ledger is in memory, so a restart clears it,
exactly as it clears mech_result's pending ids; and it keeps the last 1024
ids, so an id replayed long after that many others pays again. An id is also
bound to what it asked — reusing one for a different prompt, tool, chain, mech,
flow or request_context is refused rather than answering the wrong question.
uv sync
uv run pytest -m "not integration"Linting and CI checks mirror olas-operate-middleware (tomte toolchain via tox):
uv pip install "tomte[tox,cli]==0.7.0" tox-uv
tox -p -e flake8 -e pylint
tox -p -e black-check -e isort-check -e bandit -e safety -e mypy
tox -e unit-tests-coverage # enforces 100% coverage
GNOSIS_TESTNET_RPC=<tenderly-fork-url> tox -e integration-tests
tox -e launch-tests # opens a real terminal for codex_cli (stand-in codex)Run standalone (mimicking the Pearl runner):
export CONNECTION_LEDGER_CONFIG_LEDGER_APIS_GNOSIS_ADDRESS=<rpc-url>
export CONNECTION_CONFIGS_CONFIG_STORE_PATH=/path/to/persistent_data
export CONNECTION_CONFIGS_CONFIG_SAFE_CONTRACT_ADDRESSES='{"gnosis":"0x..."}'
export CONNECTION_CONFIGS_CONFIG_FUND_REQUIREMENTS='{"gnosis":{"agent":{"0x0000000000000000000000000000000000000000":"1000000000000000000"}}}'
# cwd must contain ethereum_private_key.txt (encrypted web3 keystore JSON)
uv run python -m connect --password-stdin # prompts "Enter password:" on a TTY
# legacy (password lands in /proc/<pid>/cmdline, readable by any local user):
uv run python -m connect --password <password>packages/ holds the Olas SDK package
tree (mirroring valory-xyz/olas-sdk-starter):
packages/valory/agents/connect— the agent blueprint (metadata; the runtime is the released binary)packages/valory/services/connect— the service package whose connection overrides define the env vars the binary consumes (CONNECTION_LEDGER_CONFIG_LEDGER_APIS_<CHAIN>_ADDRESS,CONNECTION_CONFIGS_CONFIG_{SAFE_CONTRACT_ADDRESSES,STORE_PATH,FUND_REQUIREMENTS,LOG_LEVEL})packages/packages.json— pinned hashes (dev= ours,third_party= vendorable dependencies, synced on demand)
After changing a package: autonomy packages sync && autonomy packages lock && autonomy push-all
(requires open-autonomy + open-aea-cli-ipfs). Publishing to IPFS and
minting the agent blueprint/service on the Olas Registry follow the
Pearl integration checklist.
Publishing a GitHub release triggers .github/workflows/release.yml, which:
- verifies the package hashes (
autonomy packages lock --check) and pushespackages/to the Olas IPFS registry (autonomy push-all), and - builds PyInstaller binaries named
agent_runner_{linux,macos,windows}_{x64,arm64}— the asset names Pearl's middleware downloads and sha256-verifies.
- Contributing:
CONTRIBUTING.md. - Security policy:
SECURITY.md. - Agent UI integration:
docs/agent-ui.md. - Open Autonomy framework and the Pearl integration checklist.