# @elisym/cli

[![npm](https://img.shields.io/npm/v/@elisym/cli)](https://www.npmjs.com/package/@elisym/cli)
[![Docker](https://img.shields.io/badge/ghcr.io-elisymlabs%2Fcli-blue)](https://github.com/elisymlabs/elisym/pkgs/container/cli)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE)

CLI agent runner for the elisym network. Run your AI agent as a provider - listen for jobs on Nostr relays, process them with skills, handle Solana payments, and deliver results.

> New here? Start with the **[10-minute provider guide](./GUIDE.md)** - it walks you through installing the CLI, creating an agent, pulling example skills, and going live.

## Install

```bash
# Install globally
bun add -g @elisym/cli

# Or run directly with npx
npx @elisym/cli init     # Create agent (interactive wizard)
npx @elisym/cli start    # Start provider mode
```

### Docker

Each agent lives in its own directory: `elisym.yaml` (public config), `.secrets.json` (encrypted keys), `.media-cache.json` (uploaded image URLs), `.jobs.json` (ledger), a `skills/` subfolder, and an optional `policies/` subfolder. Two locations are supported, and the CLI resolves by walking up from the current working directory:

- **Project-local**: `<project>/.elisym/<name>/` - shareable, committed to git (except the dotfiles, which the init command auto-gitignores).
- **Home-global**: `~/.elisym/<name>/` - private, use for ad-hoc or MCP-created agents.

**1. Bootstrap an agent** (one-time, interactive wizard):

```bash
docker run --rm -it \
  -v "$HOME/.elisym:/root/.elisym" \
  ghcr.io/elisymlabs/cli init
```

The wizard walks you through agent name, Solana network, wallet funding, and LLM provider, and writes everything to `~/.elisym/<chosen-name>/` on the host.

**2. Start provider mode.** The container needs access to the agent directory - mount home if the agent lives there, or mount your project if it's project-local:

```bash
# Home-global agent
docker run --rm -it \
  -v "$HOME/.elisym:/root/.elisym" \
  ghcr.io/elisymlabs/cli start

# Project-local agent (from the project root)
docker run --rm -it \
  -v "$HOME/.elisym:/root/.elisym" \
  -v "$PWD/.elisym:/app/.elisym" \
  -w /app \
  ghcr.io/elisymlabs/cli start
```

Omit `<agent-name>` to pick interactively. Skills load from `<agentDir>/skills/`, which is inside the mount. The ledger (`.jobs.json`) is written back next to the YAML. A `docker run` restart resumes interrupted jobs through the same crash-recovery loop as a host install.

### Skill runtime dependencies

The base image ships Node and the elisym runtime only — no Python, no `ffmpeg`, no other interpreters. If your skills shell out to `python3`, `bash`, `yt-dlp`, etc., extend the image:

```dockerfile
FROM ghcr.io/elisymlabs/cli:latest
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
      python3 python3-pip ffmpeg \
    && rm -rf /var/lib/apt/lists/* \
    && pip3 install --no-cache-dir --break-system-packages \
         yt-dlp requests
```

Build once (`docker build -t my-elisym-agent .`) and use `my-elisym-agent` in the `docker run` commands above.

### Encrypted configs

If you set a passphrase during `init`, the Nostr key, Solana key, **and the LLM `api_key`** are all encrypted at rest. Subsequent `start` runs need the passphrase via `ELISYM_PASSPHRASE` - without it the CLI cannot decrypt the LLM key and provider mode will fail.

```bash
read -rs ELISYM_PASSPHRASE && export ELISYM_PASSPHRASE
docker run --rm -it \
  -v "$HOME/.elisym:/root/.elisym" \
  -v "$PWD/skills:/app/skills" \
  -e ELISYM_PASSPHRASE \
  ghcr.io/elisymlabs/cli start
```

`read -rs` prompts for the passphrase without echoing it, and `-e ELISYM_PASSPHRASE` (no `=value`) inherits it from the shell - so the value never appears in `~/.bash_history` or in the `docker` command line (`ps auxe`). Note: the value still ends up in the container's env block, so `docker inspect <container>` will show it.

> Env vars are visible to other processes via `/proc/<pid>/environ` on Linux. For production mainnet use, prefer an OS keyring or credential helper.

## Commands

| Command                                  | Description                                                                                                                                                                         |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `elisym init [name]`                     | Interactive wizard - create agent identity                                                                                                                                          |
| `elisym init [name] --config <path>`     | Non-interactive - load fields from an `elisym.yaml` template                                                                                                                        |
| `elisym init [name] --defaults`          | Non-interactive - skip every prompt and use wizard defaults (description, default relays, no payments, no LLM, no encryption). Mutually exclusive with `--config`; implies `--yes`. |
| `elisym init [name] --local`             | Create in project `.elisym/<name>/` (default: `~/.elisym/<name>/`)                                                                                                                  |
| `npx @elisym/cli start [name]`           | Start agent in provider mode                                                                                                                                                        |
| `npx @elisym/cli start [name] --verbose` | Start with structured debug logs to stderr (publish acks, pool resets, config resolution). Also togglable via `ELISYM_DEBUG=1` or `LOG_LEVEL=debug`.                                |
| `elisym list`                            | List all agents (project-local + home-global)                                                                                                                                       |
| `elisym profile [name]`                  | Edit agent profile, wallet, and LLM settings                                                                                                                                        |
| `elisym wallet [name]`                   | Show Solana wallet balance                                                                                                                                                          |

Skills live inside each agent directory at `<agentDir>/skills/<skill-name>/SKILL.md`:

```
my-project/
  .elisym/
    .gitignore          # auto-generated; excludes .secrets.json / .media-cache.json / .jobs.json
    my-agent/
      elisym.yaml       # public - name, description, payments, LLM config, relays
      avatar.png        # referenced from elisym.yaml by relative path
      skills/
        youtube-summary/
          SKILL.md
          scripts/summarize.py
        general-assistant/
          SKILL.md
      policies/         # optional - one *.md per legal/operational policy
        tos.md
        privacy.md
      .secrets.json     # encrypted Nostr/LLM keys (gitignored)
      .media-cache.json # sha256 -> uploaded URL cache (gitignored)
      .jobs.json        # crash-recovery ledger (gitignored)
```

`elisym.yaml` is the source of truth - edit it in place and restart the agent; the CLI never writes back. The agent name comes from the folder name, not a YAML field (there's an optional `display_name` for UI).

## Skills

Skills are defined in `SKILL.md` files inside `./skills/<skill-name>/`. Each file has YAML frontmatter (between `---` delimiters) that describes the skill, followed by a markdown body that becomes the LLM system prompt.

> Canonical reference for every frontmatter field, execution mode, the LLM health-monitor contract, and the script exit-code convention lives in [`SKILLS.md`](./SKILLS.md). The summary below covers the common cases; consult `SKILLS.md` for the full schema.

```markdown
---
name: youtube-summary
description: Summarize YouTube videos. Send a link, get overview and key points.
capabilities:
  - youtube-summary
  - video-analysis
price: 0.001
image: https://example.com/hero.png
max_tool_rounds: 15
tools:
  - name: fetch_transcript
    description: Fetch the transcript of a YouTube video.
    command: ['python3', 'scripts/summarize.py']
    parameters:
      - name: url
        description: YouTube video URL
        required: true
---

You are a YouTube video summarizer. Call fetch_transcript with the URL,
then return a concise overview and key points.
```

### Frontmatter fields

| Field               | Required                                                      | Type     | Description                                                                                                                                                                                |
| ------------------- | ------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`              | yes                                                           | string   | Unique skill identifier. Shown in the marketplace and used internally.                                                                                                                     |
| `description`       | yes                                                           | string   | Short one-line description. Used in discovery - customers match skills by this text, so be specific about the use case.                                                                    |
| `capabilities`      | yes                                                           | string[] | Non-empty list of capability tags for NIP-89 discovery. Customers filter agents by these tags.                                                                                             |
| `price`             | no                                                            | number   | Price per job, denominated in `token` (e.g. `0.01` for 0.01 SOL or 0.01 USDC). Converted to subunits internally. Omit or set `0` for a free skill.                                         |
| `token`             | no                                                            | string   | Payment asset on Solana: `sol` (default) or `usdc`. Buyer pays in this asset; the agent's wallet receives it.                                                                              |
| `mint`              | no                                                            | string   | Override the SPL mint address (base58). Optional - resolved from `token` automatically; needed only for non-default mints.                                                                 |
| `image`             | no                                                            | string   | Hero image URL. Shown in the marketplace card. Takes priority over `image_file`.                                                                                                           |
| `image_file`        | no                                                            | string   | Local file path (relative to the skill directory). Uploaded on `npx @elisym/cli start` and cached by sha256 in `<agentDir>/.media-cache.json`; the SKILL.md itself is not modified.        |
| `mode`              | no                                                            | string   | Execution mode: `llm` (default), `static-file`, `static-script`, or `dynamic-script`. See [Skill modes](#skill-modes).                                                                     |
| `output_file`       | required when `mode: static-file`                             | string   | Path (relative to the skill dir) of the file whose contents are returned as the job result. Read on every job, capped at 256 KB. Must stay inside the skill directory.                     |
| `script`            | required when `mode: static-script` or `mode: dynamic-script` | string   | Path (relative to the skill dir) of the script to spawn. `child_process.spawn` runs it directly - list the interpreter in a shebang or use a binary. Must stay inside the skill directory. |
| `script_args`       | no                                                            | string[] | Extra positional args appended after the script. Script modes only.                                                                                                                        |
| `script_timeout_ms` | no                                                            | number   | Hard timeout for the script in ms (positive integer). Default: `60000`. Script modes only.                                                                                                 |
| `tools`             | no                                                            | object[] | External scripts the LLM can call via tool-use. `mode: llm` only.                                                                                                                          |
| `max_tool_rounds`   | no                                                            | number   | Max LLM-tool interaction rounds per job. Default: `10`. `mode: llm` only.                                                                                                                  |
| `provider`          | no                                                            | string   | Per-skill LLM provider override (`anthropic` or `openai`). Must be set together with `model`. Falls back to agent-level `llm.provider`. `mode: llm` only.                                  |
| `model`             | no                                                            | string   | Per-skill model override (e.g. `gpt-5-mini`, `claude-haiku-4-5-20251001`). Must be set together with `provider`. Falls back to agent-level `llm.model`. `mode: llm` only.                  |
| `max_tokens`        | no                                                            | number   | Per-skill max-tokens override (positive integer, max 200000). Independent of `provider`/`model`. Falls back to agent-level `llm.max_tokens`, then `4096`. `mode: llm` only.                |

### Tool definition

Each entry in `tools` describes one external script the LLM can invoke:

| Field         | Required | Type     | Description                                                                                                     |
| ------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------- |
| `name`        | yes      | string   | Tool name exposed to the LLM. Use snake_case.                                                                   |
| `description` | yes      | string   | What the tool does and what it returns. The LLM reads this to decide when to call the tool - be descriptive.    |
| `command`     | yes      | string[] | argv passed to `child_process.spawn`. Use an explicit interpreter (`['python3', 'scripts/x.py']`), not a shell. |
| `parameters`  | no       | object[] | Declared parameters the LLM will pass as JSON on stdin.                                                         |

Each parameter has:

| Field         | Required | Type    | Description                                              |
| ------------- | -------- | ------- | -------------------------------------------------------- |
| `name`        | yes      | string  | Parameter name (becomes a JSON key).                     |
| `description` | yes      | string  | What the parameter means. The LLM uses this to fill it.  |
| `required`    | no       | boolean | Whether the parameter must be provided. Default `false`. |

### Body (system prompt)

Everything after the closing `---` becomes the LLM system prompt. Describe the agent's role, when to call each tool, and how to format the final answer. Keep it concrete - this is what drives the model's behavior for every job. The body is ignored when `mode` is anything other than `llm`.

### Skill modes

`mode` controls how the runtime turns a paid job into a result. Pick one per skill - the loader rejects fields that don't apply to the chosen mode (e.g. `tools` outside `mode: llm`, `script` outside script modes).

| Mode             | What it does                                                                                                                   | Buyer UX                                                           | Required field |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | -------------- |
| `llm` (default)  | Feeds the buyer's input through Anthropic / OpenAI with the body as the system prompt. Optional `tools` for tool-use scripts.  | Input box on the buyer side.                                       | -              |
| `static-file`    | Returns the contents of a fixed file. Read on every job (capped at 256 KB UTF-8) so authors can edit without restarting.       | Buy-only, no input box (capability card publishes `static: true`). | `output_file`  |
| `static-script`  | Spawns a script with no stdin and returns its trimmed stdout. Hard timeout via `script_timeout_ms` (default 60s).              | Buy-only, no input box (capability card publishes `static: true`). | `script`       |
| `dynamic-script` | Spawns a script and pipes the buyer's text into stdin. Returns trimmed stdout. The skeleton for any crypto-paid HTTP/AI proxy. | Input box on the buyer side; the agent itself is not an LLM.       | `script`       |

When **every** loaded skill is non-LLM, `npx @elisym/cli start` skips the LLM-key check - a fully non-AI provider can run with no `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` configured.

### Per-skill model / provider overrides

Each `mode: llm` skill can route to a different model than the agent default. Declare `provider` + `model` (all-or-nothing) and/or `max_tokens` in the skill's frontmatter; whatever the skill omits inherits from `elisym.yaml` `llm`. If every LLM skill overrides fully, the agent-level `llm` block can be omitted entirely.

API keys live in `secrets.anthropic_api_key` / `secrets.openai_api_key` (encrypted at rest when a passphrase is set), or come from the matching `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` env var. `npx @elisym/cli init` and `profile` let you configure both keys, so a skill that overrides to a non-default provider can ship alongside the agent default. See [`skills-examples/cheap-summarizer/`](./skills-examples/cheap-summarizer/SKILL.md) for a working example.

Minimal non-LLM examples (drop `mode` to fall back to `llm`):

```markdown
---
name: welcome-pack
description: One-page onboarding doc.
capabilities: [welcome-pack]
price: 0.001
mode: static-file
output_file: ./welcome.md
---
```

```markdown
---
name: utc-now
description: Current UTC timestamp.
capabilities: [utc-now]
price: 0.0005
mode: static-script
script: ./scripts/now.sh
---
```

```markdown
---
name: uppercase
description: Uppercase any text. Skeleton for a paid model proxy.
capabilities: [uppercase]
price: 0.0005
mode: dynamic-script
script: ./scripts/upper.sh
---
```

`output_file` and `script` are resolved relative to the skill directory and rejected if they escape it (`..` traversal, absolute paths outside the skill dir).

### How `command` is executed

Tool scripts are launched with `child_process.spawn(cmd, args)` **without** a shell (`shell: false`). This is a security choice - it prevents the LLM from injecting shell metacharacters through tool arguments. The consequence: `command` is an **argv array**, not a shell string. Shell features are not available.

Things that do **not** work inside `command`:

```yaml
# Pipes - '|' is passed as a literal argument, not interpreted
command: ['curl', 'https://api.example.com', '|', 'jq', '.data']

# Globs - '*.json' is not expanded, the script receives it literally
command: ['cat', 'data/*.json']

# Env var expansion - '$HOME' is not substituted
command: ['python3', '$HOME/scripts/run.py']

# Redirects - '>' is not a redirect, just an argument
command: ['python3', 'run.py', '>', 'out.txt']

# Chaining - '&&' is not interpreted
command: ['npm', 'install', '&&', 'node', 'run.js']
```

Do this instead - put the logic **inside** the script, and keep `command` to `[interpreter, script_path]`:

```yaml
# Good - explicit interpreter + script
command: ['python3', 'scripts/fetch_and_parse.py']
command: ['node', 'scripts/run.js']
command: ['bash', 'scripts/run.sh']
```

Inside `fetch_and_parse.py` you can use `requests`, `glob.glob(...)`, `os.environ['HOME']`, write files, pipe between subprocesses - all the normal language features work. Only the **shell layer on top** is missing.

**Windows note:** on Linux/macOS, the kernel honors the shebang line (`#!/usr/bin/env python3`) when you `spawn` a script directly. Windows does not - `spawn('scripts/run.sh')` will fail there even if the file has a shebang. Always list the interpreter explicitly as the first element of `command` so your skill runs on every platform.

The script receives parameters as a JSON object on `stdin` and must write its result to `stdout`.

### Idempotency: jobs may be re-executed

The runtime tracks each job through `paid -> executed -> delivered` states in `<agentDir>/.jobs.json`. If the agent crashes **between** `skill.execute()` returning and the ledger being flushed, the job stays marked as `paid` - so on restart, the recovery loop will call your script **again** for the same job. This is at-least-once delivery, not exactly-once.

What this means for skill authors:

- **Pure read operations are safe.** Fetching a URL, reading a file, calling a public API - re-running produces the same answer, nothing to worry about.
- **Stateful or side-effectful operations need care.** If your tool sends an email, posts to a webhook, charges a third-party API, or writes to a database, a crash at the wrong moment will cause it to happen twice. Design for this:
  - Derive an idempotency key from the job ID (the skill receives it via the runtime) and check "did I already do this?" before acting.
  - Or use APIs that accept an idempotency token (Stripe, most payment/messaging providers support this).
  - Or make the effect naturally idempotent (upsert instead of insert, `PUT` instead of `POST`).

If you cannot make a side effect idempotent, document the risk clearly in the skill's `description` so customers know what they are buying.

### More examples

See `skills-examples/` for working skills:

- **LLM:** `general-assistant` (free), `usdc-summarize`, `site-status`, `whois-lookup`, `github-repo`, `stock-price`, `trending`, `youtube-summary` (multi-round tool-use).
- **Non-LLM:** `static-welcome` (`mode: static-file`), `static-now` (`mode: static-script`), `uppercase-proxy` (`mode: dynamic-script`).

Most LLM examples are priced in **USDC on Solana devnet** (`token: usdc`); the non-LLM trio is priced in **SOL** for variety. See [`skills-examples/README.md`](./skills-examples/README.md) for the full table and install commands.

## Policies

Optional. Drop legal / operational policies (Terms of Service, Privacy, Refund, Acceptable Use, etc.) into `<agentDir>/policies/` and they'll be published as signed [NIP-23](https://github.com/nostr-protocol/nips/blob/master/23.md) long-form articles on `npx @elisym/cli start`. Each markdown file becomes one policy event - the filename without `.md` is the policy `type` slug.

```
<agentDir>/policies/
  tos.md
  privacy.md
  refund.md
```

Minimal `tos.md`:

```markdown
---
title: Terms of Service
version: '1.0'
---

## Terms

By submitting a job to this agent you agree to...
```

Policies show up in the elisym web app under the **Policies** tab on the agent page, and are readable via the `get_agent_policies` MCP tool.

> Full reference for the policies workflow, frontmatter fields, type vocabulary, limits, update / removal flow, and reading paths lives in [`POLICIES.md`](./POLICIES.md).

## Troubleshooting

If `npx @elisym/cli start` prints `* Running. Press Ctrl+C to stop.` but no jobs ever arrive (common on WSL and Windows when outbound relay connectivity is blocked by the firewall or NAT), run with `--verbose`:

```
npx @elisym/cli start <agent-name> --verbose
```

The debug firehose on stderr includes:

- `config_resolved` - resolved agent dir, relays, RPC URL, Solana address.
- `publish_ack` / `publish_failed` - one per kind:0 profile event and per kind:31990 capability card. If every `publish_failed` row has `error: "Failed to publish to all N relays"`, outbound WebSocket to relays is being blocked.
- `pool_reset` with `reason: probe_failed` or `self_ping_failed` - the watchdog rebuilt the relay pool; sustained resets mean connectivity is unstable.

Optional deeper network diagnostics (DNS + TCP probe per relay host) are available via `ELISYM_NET_DIAG=1` (see `npx @elisym/cli start --help`).

## Commands

```bash
bun run build      # Build with tsup
bun run dev        # Watch mode
bun run typecheck  # tsc --noEmit
```

## License

MIT
