Procyon
A terminal development harness for Stellar and Soroban smart contracts, with a language model driving the tools.
You describe what you want in plain language. Procyon gives the model a set of tools scoped to your workspace — search the code, read and edit files, build, deploy, invoke, inspect events, look facts up through MCP — and shows the work as it happens in a terminal UI.
Twelve providers are supported. Claude over Anthropic's own API is the default; DeepSeek, Groq, OpenRouter, xAI and the rest speak the OpenAI dialect, and Ollama or LM Studio run a model on your own machine with no account at all. See Providers.
┌ Chat - 12 messages ────────────────────────┐┌ Status ─────────┐
│ You: why does my transfer fail with ││ Status: Working │
│ HostError(Storage, MissingValue)? ││ Network: testnet│
│ System: Using tool: raven__search ││ Explain: off │
│ Agent: That variant means a contract read │└─────────────────┘
│ hit a key that was never written. In │┌ Project ────────┐
│ your `transfer` the balance is read ││ Project: demo │
│ before `initialize` has run… │└─────────────────┘
└────────────────────────────────────────────┘┌ Account ────────┐
┌ Input (type /help for commands) ───────────┐│ Account: alice │
│ ▏ │└─────────────────┘
└────────────────────────────────────────────┘
Project status
Early and honest about it. The core loop works — streaming responses, tool calling, workspace confinement, context compaction, MCP, session persistence, twelve providers — and there are 369 tests plus CI. But:
- Network tool coverage is uneven.
generate_docshas a test that fetches a real contract interface from testnet. The event tools were validated by hand against testnet RPC but have no automated test. Thecaatinga_*flags are verified against the pinned CLI version, but the deploy and invoke paths depend on your own project's config and have not been run end to end here. - There is no interactive approval prompt. File writes are confined to the workspace but are not individually confirmed, and mainnet is a switch rather than a per-operation confirmation. See Safety.
- Sub-agents get no tools.
talk_toandparty_moderun each persona with an empty tool registry, so a persona can reason and answer but cannot read your files or run a build. Passing the parent registry down is the next step there. - Skills and MCP servers are discovered once per run, so adding either means restarting. An MCP server's tool set is fixed at that point too: one reconnected later is not re-listed.
- OAuth needs port 8181 free for the redirect, and the sign-in must be completed in one run: the PKCE verifier is held in memory, so quitting between opening the browser and the redirect restarts the flow.
- No compression or indexing of session logs. They are plain JSONL and grow with the conversation.
- Diagnostics are opt-in. Anomalies that are recovered from rather than fatal — bytes replaced
in a stream, frontmatter this parser cannot model — are recorded only if
PROCYON_LOGnames a file. A TUI owns the terminal, so there is nowhere else to print them. - The plugin system is minimal — manifests can register external commands as tools, nothing more.
Licensed under Apache-2.0. Not published to crates.io yet — see Install.
Requirements
| Rust | 1.88 or newer (ratatui 0.30 requires it) |
| A provider credential | Required, unless you run a local model. See Providers. |
stellar CLI |
Recommended — used for account keys, contract interfaces, and as an invoke fallback |
Node.js / npx |
Optional — only for the caatinga_* tools, see Caatinga |
| An MCP server | Optional but strongly recommended, see Grounding |
Install
Procyon is one binary, procyon, that opens a terminal UI. It is not on crates.io yet, so today
you build it from source:
That puts procyon on your PATH, which is how it is meant to be used — the workspace is
whatever directory you launch it from, so the binary has to be callable from your projects rather
than from its own checkout. If you would rather not install it, cargo build --release leaves the
binary at target/release/procyon.
Either way the first build takes a while: ratatui, reqwest, rmcp and tera all compile from
source.
Once it is published, this becomes:
Configure
Procyon reads a .env from the working directory, or plain environment variables. Copy the
example and fill it in:
# The credential for whichever provider you use. Anthropic is the default.
ANTHROPIC_API_KEY=sk-ant-...
# For an OpenAI-dialect provider instead. The variable name depends on the provider —
# DEEPSEEK_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, and so on.
OPENAI_API_KEY=
# Optional: credential for an MCP server declared in config.toml (see below).
PROCYON_MCP_TOKEN=
# Optional: where to append diagnostics. Unset means they are kept in memory only.
PROCYON_LOG=
# Optional: let the signing tools act on the public network, for this run only. Off by default.
PROCYON_ALLOW_MAINNET=
.env is gitignored. There is also an optional ~/.config/procyon/config.toml. Every field has
a default, so set only what you want to change:
= "testnet" # local | testnet | mainnet
= "dark" # dark | light
= "anthropic" # see Providers
= "claude-sonnet-5"
= 4096
= false # signing on the public network — see Safety
# base_url = "..." # only for provider = "openai-compatible"
# api_key_env = "MY_KEY" # variable holding the credential
# anthropic_api_key = "sk-ant-..." # prefer .env — see Safety
# MCP servers. Their tools are namespaced <name>__<tool>.
# Remote, with browser sign-in (OAuth 2.1 + PKCE):
[[]]
= "raven"
= "https://raven.stellar.org/mcp"
= "oauth"
# Remote, with a static bearer credential instead:
[[]]
= "internal"
= "https://mcp.example.com/mcp"
= "PROCYON_MCP_TOKEN" # variable name, never the token itself
# Local, over stdio. Inherits this process's environment; no token_env.
[[]]
= "fs"
= "npx"
= ["-y", "@modelcontextprotocol/server-filesystem", "."]
Each entry sets url or command, never both — a malformed entry is reported by name at
startup instead of failing as an opaque transport error.
Spellings are lowercase. A malformed config fails before the terminal switches to raw mode, so you get a readable error naming the file and line rather than a scrambled screen.
Providers
Two protocols exist as far as the code is concerned: Anthropic's /v1/messages and the OpenAI
/chat/completions dialect. Everything else is a named profile over the second one — a preset
endpoint and credential variable, so provider = "groq" is all the config needs and you never
have to look an endpoint up.
provider |
Endpoint | Credential variable |
|---|---|---|
anthropic |
api.anthropic.com |
ANTHROPIC_API_KEY |
openai |
api.openai.com/v1 |
OPENAI_API_KEY |
deepseek |
api.deepseek.com/v1 |
DEEPSEEK_API_KEY |
groq |
api.groq.com/openai/v1 |
GROQ_API_KEY |
openrouter |
openrouter.ai/api/v1 |
OPENROUTER_API_KEY |
cerebras |
api.cerebras.ai/v1 |
CEREBRAS_API_KEY |
fireworks |
api.fireworks.ai/inference/v1 |
FIREWORKS_API_KEY |
togetherai |
api.together.xyz/v1 |
TOGETHER_API_KEY |
xai |
api.x.ai/v1 |
XAI_API_KEY |
ollama |
localhost:11434/v1 |
none needed |
lmstudio |
localhost:1234/v1 |
none needed |
openai-compatible |
your base_url |
OPENAI_API_KEY, or api_key_env |
An explicit base_url overrides any profile, so a named provider can still be pointed at a proxy
or a self-hosted gateway without losing its credential preset. An endpoint on localhost needs no
credential at all — which is what makes Ollama the shortest path to running Procyon with no
provider account.
Switch at runtime with /model; the change takes effect on the next message, and the conversation
carries over. Anthropic-only features degrade rather than break: prompt-cache breakpoints are
Anthropic's, and the OpenAI dialect reports cache hits differently, so the budget estimator reads
whichever shape it is given.
Run
Procyon treats the directory you launch it from as the workspace. Every file tool is confined
to it, so launch from your project root. Working on Procyon itself instead of with it?
cargo run --release from the checkout does the same thing.
Keys
| Key | Action |
|---|---|
Enter |
Send the message |
← → |
Move the input cursor |
Home / End, or Ctrl+A / Ctrl+E |
Jump to start / end of the input |
Backspace Delete |
Delete before / at the cursor |
↑ ↓ |
Scroll the chat one line |
PageUp PageDown |
Scroll ten lines |
Ctrl+B |
Ask the agent to build |
Ctrl+T |
Ask the agent to run tests |
Ctrl+D |
Ask the agent to deploy |
Ctrl+C |
Quit |
Scrolling is anchored: while you are reading back through history, incoming messages do not yank the view. Scroll to the bottom and it resumes following automatically.
Commands
| Command | Action |
|---|---|
/help |
List commands and shortcuts |
/status |
Connection, network, and active account |
/project |
Current project and network |
/network <local|testnet|mainnet> |
Switch the active network |
/explain |
Toggle explain mode — the agent narrates each step before taking it |
/model |
Current provider and model, with suggestions for that provider |
/model set <provider> <model> |
Switch both at once |
/model provider <name> |
Switch provider only |
/model model <name> |
Switch model only |
/clear |
Clear the chat |
Sessions
Every conversation is written to an append-only log as it happens, so nothing is lost if Procyon or the machine goes down.
Logs live under ~/.local/share/procyon/sessions/<project>/<id>.jsonl, one file per session with
a self-describing header on line 1 — listing reads only that line, so it stays instant however
long the conversations get.
What is stored is the sequence of events, not the messages; the transcript is derived by folding
them. That is what makes crash recovery work: the log is flushed to disk before each request to
the model and before each tool runs, so on resume Procyon knows a tool may have acted. Every
tool_use left unanswered by the crash gets a synthetic result saying so — distinguishing "it
started, verify before retrying" from "it never ran, safe to retry" — because a transcript with an
unanswered call is rejected by the API outright.
A record torn in half by a crash is discarded and physically truncated, and a log written by a newer Procyon is refused with an upgrade message rather than half-read.
Compaction is part of the log too: the checkpoint is recorded with how many messages it replaced, so a resumed session rebuilds the compacted history rather than the long one it stood in for.
Tools
Twenty-nine built-in tools plus spawn_agent, and whatever your MCP servers expose. The agent
picks them; you do not call them directly.
Navigating your code — list_dir, glob, grep, read_file
Changing your code — write_file, edit_file
Projects — project_init (scaffolds a project, optionally with a token contract),
project_info
Build, deploy, invoke — caatinga_build, caatinga_deploy, caatinga_read,
caatinga_invoke, caatinga_doctor, stellar_invoke — see Caatinga
Accounts — account_create, account_list, account_balance
Testing and inspection — run_tests, get_contract_events, filter_contract_events,
generate_bindings, generate_docs
Skills and personas — list_skills, run_skill, list_personas, talk_to, party_mode,
spawn_agent — see Skills and personas
Housekeeping — list_plugins, check_update
Generated contracts
project_init with the token template scaffolds a Soroban contract that compiles, carries a
#[contracterror] enum, and ships with five passing tests. Build it for WebAssembly with:
soroban-sdk 27 rejects wasm32-unknown-unknown — the older target that most tutorials still
mention — so that flag matters.
Caatinga
Deploys go through @caatinga/cli, which sits
between "contract is written" and "contract is deployed with typed bindings". The caatinga_*
tools only run in a project that has a caatinga.config.ts — every one of them takes a
contract name from that config, so without it there is nothing to resolve, and they say so
instead of failing inside npx. For a one-off contract in a project Caatinga does not manage, the
stellar CLI is the right path and stellar_invoke is there for it.
What the tools deliberately do not accept is as important as what they do:
| Not a parameter | Where it comes from instead |
|---|---|
| A wasm path | caatinga.config.ts, via the contract name |
| A contract id | caatinga.artifacts.json, the versioned source of truth per network |
| A bindings output directory or language | caatinga.config.ts |
That is the whole point of the integration. A contract id copied out of a deploy log is stale the
next time anything is redeployed, and bindings written somewhere the config does not expect are
bindings the app never imports. caatinga_deploy reports the ids it recorded by reading them back
from the artifacts, so there is nothing to copy.
Deploying without naming a contract deploys all of them in dependency order — Caatinga derives
that order from dependsOn, and it also regenerates bindings, runs wiring hooks and syncs frontend
env afterwards. dry_run estimates the cost without submitting anything.
Signing is always by identity alias, such as alice. Every tool that takes a source rejects
a secret key, a seed phrase or a raw address before spawning anything, and the rejection never
echoes the value back: a secret on a command line reaches the process list, the error text, the
model's context and the session log on disk. Key material stays in the stellar CLI keystore.
Reach for caatinga_read rather than caatinga_invoke whenever you only need to read a value — it
simulates, signs nothing and submits nothing. And caatinga_doctor is the first thing to run when
something fails for a reason that is not in the contract, since most of those are environment
drift.
The CLI version is pinned in Procyon rather than floating, so the same Procyon keeps behaving the same way. The flags were verified against the pinned version; the paths themselves have not been run end to end here, which is the caveat in Project status.
Skills and personas
A skill is a directory holding a SKILL.md: YAML frontmatter with a name and description,
then a Markdown body of instructions. The body is loaded as context on demand, which is what makes
a skill cheap — a hundred installed skills cost you two lines of description each in the prompt,
not their full text.
Skills are discovered from four places, first match by name winning:
~/.claude/skills/<name>/SKILL.md # global, shared with Claude Code
~/.config/procyon/skills/<name>/SKILL.md # global, Procyon's own
.procyon/skills/<name>/SKILL.md # project
.stellar-build/skills/<name>/SKILL.md # project
A skill that also carries a customize.toml with an [agent] section is a persona: a name,
title, icon, role, identity, communication style and principles, assembled into a system prompt.
talk_to runs one as a sub-agent with its own conversation; party_mode runs several in parallel
and returns each perspective, five at a time, with a 120-second deadline per persona.
You: party mode — should this contract be upgradeable?
🏗 Tyler (2 round trips, 4.1s):
Upgradeability is a governance question before it is a technical one…
💻 Elliot (1 round trip, 2.8s):
If you do, the storage layout has to be versioned from day one…
Sub-agents run on the same provider and model as the main loop unless told otherwise, and each request they make is bounded — 120 seconds by default, so a provider that accepts a connection and then goes quiet fails with a message instead of hanging. They currently run with an empty tool registry; see Project status.
Malformed frontmatter is reported and the skill is skipped, never fatal. Sequences are understood
in both YAML spellings — tools: [a, b] and the indented - a form — because a SKILL.md any
YAML parser accepts should not vanish from the registry over its punctuation.
Grounding
The difference between an agent that sounds knowledgeable about Stellar and one that is comes down to whether it can check.
Workspace context, rebuilt every turn. Before each request Procyon tells the model where it
is: the workspace path, the current project and version, the default network, registered
contracts and their addresses, configured accounts, which of stellar/npx are installed, and
which MCP servers are connected. It is rebuilt per turn, because the agent changes those things
as it works.
Lookup instead of recall, over MCP. Procyon is an MCP client, over streamable HTTP for
remote servers and stdio for local ones. Declare a server in config.toml and its tools join the
registry at startup, namespaced <name>__<tool>. The namespace is load-bearing, not cosmetic:
the reference filesystem server exposes read_file and write_file, which would otherwise
shadow Procyon's own. The system prompt tells the model to look Stellar facts up rather than
recall them, and to say so plainly when no source is connected.
Stellar Raven is the intended companion: search and execute over
a catalog of Stellar ecosystem services and skills. Declare it with auth = "oauth" and sign in
once:
That runs the OAuth 2.1 authorization-code flow with PKCE: it discovers the server's metadata,
registers Procyon as a client dynamically, opens your browser, and receives the redirect on
http://127.0.0.1:8181/callback. Credentials are stored per server under
~/.local/share/procyon/oauth/<name>.json with mode 0600, since the file holds a refresh token.
Access tokens are refreshed automatically; you only see the browser again when the refresh token
itself expires.
Servers that issue static bearer credentials are supported too, via token_env.
A server that fails to connect is reported in the chat and skipped; an outage never stops Procyon from starting. A stdio server's stderr is piped and its last lines are quoted back on a failed handshake, since that message is usually the only explanation for why a local server died.
Connections recover on their own at two levels. The transport handles an expired HTTP session (404) by replaying the handshake once and retrying the in-flight call. Above that, Procyon drops a connection whose tool call fails at the transport layer — a healthy server reports tool errors in the payload, so a transport error means the link itself is broken — and the next call rebuilds it. That covers a stdio child exiting and an HTTP host restarting. Repeated failures back off for a few seconds and report the cause rather than dialing in a loop.
With no server connected, Procyon still runs. It just says so in the system prompt, and the agent has no way to look anything up.
Safety
Workspace confinement. Every file tool resolves paths against the launch directory and rejects anything outside it. The check is not merely textual: a path that does not exist yet but sits behind a symlinked parent pointing out of the tree is rejected too. The Stellar docs are a second, read-only root with the same guard rather than a hole in the first.
Secrets stay out of the repo. .gitignore covers .env and .procyon/. Procyon never reads
or stores Stellar secret keys — they remain in the stellar CLI keystore, referenced by name.
Provider and MCP credentials are named by variable in config.toml, never written into it:
api_key_env and token_env hold the name of the variable, not the value.
The one exception is the legacy anthropic_api_key field, kept for configs written before
provider existed. Procyon does not write config.toml itself today, so if you hand-create one
containing that field, its permissions are yours to set (chmod 600). Prefer .env or an
environment variable. The chmod-0600 path exists in the code but only runs once a settings writer
is wired up.
Mainnet is off until you turn it on. Every tool that signs and submits refuses the public
network unless allow_mainnet = true is in config.toml, or PROCYON_ALLOW_MAINNET=1 is in the
environment for a single run. This is a real gate, not a prompt instruction: the agent cannot grant
it to itself mid-conversation, and cannot argue its way past it. The refusal names what you would
have to set, and nothing is submitted.
Those same tools also require the network as an explicit argument — there is no default. A
signing operation that left it implicit would inherit a default Procyon cannot see (Caatinga's
lives in caatinga.config.ts), so "unspecified" would be indistinguishable from "mainnet", and a
gate with that hole in it is decoration. Requiring it also puts the target in the session log.
Two limits worth knowing. The gate matches the network by name — mainnet, public, pubnet,
main — so a custom network in caatinga.config.ts that points at the public network under
another name is outside what it can detect. And it is a switch, not an approval prompt: once
enabled it stays enabled for the session, because a tool runs with no channel to ask the UI
anything. Per-operation confirmation is still missing, and reads are deliberately ungated —
caatinga_read simulates, so it costs nothing.
Long conversations. At 80% of the context window Procyon summarizes the older turns into a checkpoint and keeps the recent ones verbatim. The cut point is chosen so no tool call is ever separated from its result; if no safe cut exists, it refuses to compact rather than corrupt the transcript.
Architecture
flowchart LR
subgraph main["main thread"]
UI["Ratatui UI<br/>render + keys"]
end
subgraph stdin["blocking thread"]
READER["event reader"]
end
subgraph task["tokio task"]
AGENT["agent loop<br/>context · budget · tools"]
end
READER -->|events| UI
UI -->|UserCommand| AGENT
AGENT -->|AgentUpdate| UI
AGENT --> LLM["provider dispatch"]
LLM -->|/v1/messages| ANTHROPIC["Anthropic"]
LLM -->|/chat/completions| OPENAI["OpenAI dialect<br/>11 providers"]
AGENT --> TOOLS["tool registry"]
TOOLS --> FS["workspace<br/>(confined)"]
TOOLS --> DOCS["Stellar docs<br/>(read-only)"]
TOOLS --> CLI["stellar / npx"]
TOOLS --> RPC["Stellar RPC<br/>Horizon"]
TOOLS --> SUB["sub-agents<br/>personas · party"]
A dedicated blocking thread owns stdin, because a cancelled spawn_blocking read would swallow
keystrokes. The UI and the agent never share state — they exchange UserCommand and
AgentUpdate over unbounded mpsc channels.
The module boundary that matters most is between the agent's vocabulary and any provider's wire format:
| Module | Holds |
|---|---|
agent |
Message, ContentPart, ToolDefinition, TokenUsage — no wire format anywhere |
anthropic, openai |
one client, one wire, one stream each |
llm |
provider dispatch, and the one shared HTTP connection pool |
sse |
event framing and the read loop both adapters share |
registries |
skills and personas, discovered once per process |
ContentPart is what the session log persists, so its serialized shape is a durability
commitment — a resumed log has to deserialize what an earlier run wrote. A provider's request and
event shapes answer to whatever that API asks for this month. Keeping the two apart is what lets a
vendor revision stay a change to one wire.rs.
The two event grammars genuinely differ — Anthropic streams indexed blocks, the OpenAI dialect streams choice deltas — so each adapter keeps its own state machine. What they share is the reading: frame the chunk, hand over each payload, drain the decoder when the connection ends, stop when the UI hangs up.
Development
The eight ignored tests are kept out of the fast suite because each needs something the machine may not have:
| Test | Needs |
|---|---|
scaffolded_contract_builds_and_passes_its_own_tests |
Cargo + the wasm32v1-none target; downloads soroban-sdk |
project_init_tool_end_to_end |
Same, and drives project_init itself rather than the scaffold helper |
fetches_a_real_contract_spec_from_testnet |
Network + the stellar CLI |
raven_live_lists_and_calls_search |
A reachable MCP server and PROCYON_MCP_TOKEN |
raven_oauth_live_connects_with_stored_credentials |
A server authorized with --authorize |
stdio_live_lists_filesystem_tools |
Node; launches a real stdio MCP server over npx |
stdio_live_reconnects_after_the_server_dies |
Node; kills a live server and checks recovery |
dump_real_prompt |
Nothing, but prints the assembled system prompt — run with --nocapture |
CI runs three jobs: lint, tests, and one that scaffolds the token template and compiles it for
wasm32v1-none — the template is otherwise a file nobody builds. The network-dependent tests stay
out of CI on purpose, to keep it from going red on someone else's outage.
Dead code that is genuinely waiting on a later milestone carries a targeted #[allow(dead_code)]
with a reason, so -D warnings stays meaningful and any new warning is a real regression.
Adding a tool
Implement Tool in a module under src/tools/, then register it in agent_task:
Two rules worth internalizing: resolve any caller-supplied path through
tools::paths::resolve_in_workspace, and never block the runtime — use tokio::fs,
tokio::process, or spawn_blocking, or the UI freezes while your tool runs.
License
Apache-2.0. The license grants patent rights alongside the copyright ones, which is why it is the choice here rather than MIT: Procyon drives deploys and signs nothing itself, but it sits next to code that handles value.