# 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](#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](#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](LICENSE). Not published to crates.io yet — see [Install](#install).
## Requirements
| Rust | 1.88 or newer (`ratatui` 0.30 requires it) |
| A provider credential | Required, unless you run a local model. See [Providers](#providers). |
| [`stellar` CLI](https://developers.stellar.org/docs/tools/developer-tools/cli/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](#caatinga) |
| An MCP server | Optional but strongly recommended, see [Grounding](#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:
```bash
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:
```bash
cargo install procyon
```
## Configure
Procyon reads a `.env` from the working directory, or plain environment variables. Copy the
example and fill it in:
```bash
cp .env.example .env
```
```bash
# 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:
```toml
default_network = "testnet" # local | testnet | mainnet
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.
| `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
```bash
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
| `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
| `/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.
```bash
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 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](#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](#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:
```bash
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`](https://www.npmjs.com/package/@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:
| 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](#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](#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](https://raven.stellar.org) 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:
```bash
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
```mermaid
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:
| `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
```bash
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:
| `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`:
```rust
#[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](LICENSE). 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.