procyon 0.0.1

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
procyon-0.0.1 is not a library.

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_docs has 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. The caatinga_* 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_to and party_mode run 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_LOG names 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:

git clone https://github.com/Dione-b/procyon.git
cd procyon
cargo install --path .

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:

cargo install procyon

Configure

Procyon reads a .env from the working directory, or plain environment variables. Copy the example and fill it in:

cp .env.example .env
# 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:

default_network = "testnet"          # local | testnet | mainnet
theme = "dark"                       # dark | light
provider = "anthropic"               # see Providers
default_model = "claude-sonnet-5"
max_tokens = 4096
allow_mainnet = 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):
[[mcp_servers]]
name = "raven"
url = "https://raven.stellar.org/mcp"
auth = "oauth"

# Remote, with a static bearer credential instead:
[[mcp_servers]]
name = "internal"
url = "https://mcp.example.com/mcp"
token_env = "PROCYON_MCP_TOKEN"      # variable name, never the token itself

# Local, over stdio. Inherits this process's environment; no token_env.
[[mcp_servers]]
name = "fs"
command = "npx"
args = ["-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

cd ~/my-soroban-project
procyon

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.

procyon                  # new session
procyon --sessions       # list sessions for this directory
procyon --resume         # resume the most recent one
procyon --resume <id>    # resume a specific one

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 codelist_dir, glob, grep, read_file

Changing your codewrite_file, edit_file

Projectsproject_init (scaffolds a project, optionally with a token contract), project_info

Build, deploy, invokecaatinga_build, caatinga_deploy, caatinga_read, caatinga_invoke, caatinga_doctor, stellar_invoke — see Caatinga

Accountsaccount_create, account_list, account_balance

Testing and inspectionrun_tests, get_contract_events, filter_contract_events, generate_bindings, generate_docs

Skills and personaslist_skills, run_skill, list_personas, talk_to, party_mode, spawn_agent — see Skills and personas

Housekeepinglist_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:

cargo build --release --target wasm32v1-none

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:

procyon --authorize raven

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

cargo test                 # 369 tests, no network, no toolchain
cargo test -- --ignored    # 8 tests needing the network or an installed toolchain
cargo clippy --all-targets -- -D warnings
cargo fmt --all --check

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:

#[async_trait]
impl Tool for MyTool {
    fn name(&self) -> &str { "my_tool" }
    fn description(&self) -> &str { "What it does, written for the model" }
    fn input_schema(&self) -> Value { json!({ "type": "object", "properties": {} }) }
    async fn execute(&self, input: Value) -> Result<String, String> { todo!() }
}

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.