/new-cli-action — Scaffold a New CLI Action
Arguments passed: $ARGUMENTS
You are helping the user add a new command to the Phantom CLI (packages/cli). Every action is automatically exposed in three places: the CLI binary, the MCP server, and OpenClaw. All three come for free from a single createAction call — the user only needs to write one file and make two registration edits.
Framework: The CLI is built on incur, a TypeScript framework for building CLIs that work as both binaries and MCP servers. The
Cliandzimports in every action file come fromincur. If you need deeper guidance on incur patterns — schemas, Cli APIs, or advanced usage — runnpx incur skills addin the project root to pull the official incur skill into Claude Code and piggyback off it.
Step 1 — Understand the requirement
If $ARGUMENTS is empty or vague, ask the user:
- What should the command do? (one sentence)
- What inputs does it need?
- What does a success response look like?
- Where should it live in the CLI? (top-level like
phantom buy, or under a sub-group likephantom perps open)
If $ARGUMENTS describes the command well enough, proceed directly.
Step 2 — Determine the file location and CLI group
The CLI is structured as follows. Read packages/cli/src/index.ts and the relevant command file to confirm the current shape before writing anything.
| CLI path | Command file | Action files |
|---|---|---|
phantom <cmd> |
src/index.ts (registered directly) |
src/actions/<name>.ts |
phantom wallet <cmd> |
src/commands/wallet.ts |
src/actions/<name>.ts |
phantom solana <cmd> |
src/commands/solana.ts |
src/actions/<name>.ts |
phantom evm <cmd> |
src/commands/evm.ts |
src/actions/<name>.ts |
phantom perps <cmd> |
src/commands/perps.ts |
src/actions/<name>.ts |
phantom token <cmd> |
src/commands/token.ts |
src/actions/<name>.ts |
If the command belongs to a new group that doesn't have a command file yet, create src/commands/<group>.ts following the pattern of an existing one (e.g. wallet.ts), then register <group>Cli in src/index.ts.
Decide the kebab-case filename: src/actions/<verb>-<noun>.ts (e.g. get-perp-account.ts, send-evm-transaction.ts).
Step 3 — Write the action file
Create packages/cli/src/actions/<name>.ts using the template below. Fill every section — do not leave TODOs or placeholders.
/**
* <mcp_command_name> tool
*
* <One or two sentences describing what this action does and any important
* preconditions (e.g. "Wallet must hold USDC in the perps account").>
*/
import { Cli, z } from "incur";
import { createAction } from "../utils/actions.js";
// Import helpers as needed:
// Session/wallet: import { WalletIdSchema, DerivationIndexSchema } from "../utils/schemas.js";
// Solana address: import { getSolanaAddress } from "../utils/solana.js";
// EVM address: import { getEthereumAddress } from "../utils/evm.js";
// Perps client: import { createPerpsClient } from "../utils/perps.js";
// Shared schemas: import { ActionResponseSchema, PendingConfirmationSchema } from "../utils/output-schemas.js";
const <PascalName>Schema = z.object({
// Each field needs a .describe() explaining its purpose and any defaults.
// Always include these at the bottom if the action touches the wallet:
// walletId: WalletIdSchema.describe("Optional wallet ID (defaults to authenticated wallet)"),
// derivationIndex: DerivationIndexSchema.describe("Optional derivation index (default: 0)"),
});
const <PascalName>OutputSchema = z.object({
// Define every field the run() function returns.
// Use z.string().nullable() when a field can be null.
// Use .optional() for fields that are sometimes absent.
});
const <camelName>Action = createAction({
// description: Shown to the LLM agent. Should be detailed enough that the
// agent knows when and how to use this tool. Include:
// - What it does
// - Any required preconditions
// - What the success response looks like
// - Any important defaults or two-step flow notes
description:
"Phantom Wallet — <detailed description>.",
options: <PascalName>Schema,
output: <PascalName>OutputSchema,
mcp: {
// command: The MCP/RPC method name. Use snake_case. Should be more
// descriptive than the CLI sub-command name because MCP tools are flat
// (no nesting), e.g. "get_perp_account" not just "account".
command: "<snake_case_mcp_name>",
annotations: {
// readOnlyHint: true → no side effects (reads only)
// readOnlyHint: false → has side effects (writes, transactions)
readOnlyHint: <true|false>,
// destructiveHint: true → irreversible (sends tx, deletes data)
destructiveHint: <true|false>,
// openWorldHint: true → makes network/external calls
openWorldHint: <true|false>,
},
},
run: async ({ options: params, var: context }) => {
const { logger } = context;
const session = context.manager.getSession();
// context.manager.getClient() → PhantomClient (sign, send, addresses)
// context.apiClient → PhantomApiClient (REST endpoints)
// session.walletId → currently authenticated wallet ID
// session.organizationId → org ID for the current session
// A session is automatically initialized on first use and persisted
// across calls — no manual session management required.
// ... implementation ...
return { /* must match <PascalName>OutputSchema */ };
},
});
export const <camelName>Command = Cli.create("<cli-subcommand>", <camelName>Action.command);
export const <camelName>Tool = <camelName>Action.tool;
Key rules when filling out the template
- Input schema first, output schema second — the input schema is defined above the output schema in every action file.
descriptionon every input field — eachz.*field must have.describe(...). Include the default value in the description text if one exists (e.g."Default: false.").- No
output: z.any()— always define a concrete output schema. mcp.commandvs CLI sub-command — the MCP command name is flat and globally unique (get_perp_account); the CLI sub-command is the leaf word under its group (accountunderphantom perps).readOnlyHint: trueonly for pure reads with no side effects.- Shared output schemas — if the output shape is also used by another action, add it to
src/utils/output-schemas.tsand import it. Otherwise define it inline. ActionResponseSchema— use for any Hyperliquid write operation that returns{ status, data? }.PendingConfirmationSchema— use in a union for two-step flows (simulate first, then confirm).
Step 4 — Register the CLI command
Open the relevant command file and add one import + one .<command>() call.
Example — adding to perps:
// In src/commands/perps.ts
import { <camelName>Command } from "../actions/<name>.js";
// ...
perpsCli.command(<camelName>Command);
Example — adding top-level to src/index.ts:
import { <camelName>Command } from "./actions/<name>.js";
// ...
cli.command(<camelName>Command);
Step 5 — Register the MCP tool for OpenClaw
Open src/tools/index.ts, add the import, and add the tool to the tools array in the appropriate section:
import { <camelName>Tool } from "../actions/<name>.js";
export const tools: ToolHandler[] = [
// ... existing tools ...
<camelName>Tool, // add in the logical group (wallet / solana / evm / perps read / perps write)
];
Step 6 — Verify
Run the TypeScript check to confirm zero errors:
cd packages/cli && npx tsc --noEmit
If there are type errors, fix them before reporting the work as done. Common issues:
- Return type of
run()doesn't match<PascalName>OutputSchema— check every return path. z.string().nullable()needed where the value can benull.- Missing
as conston literal returns inside discriminated unions.
Step 7 — Update documentation
Four places need updating. Do all four before reporting done.
1. packages/cli/src/index.ts — MCP_INSTRUCTIONS
Add the new tool to the Available tools: sentence in MCP_INSTRUCTIONS:
"<mcp_command> (<one-line description>), " +
2. packages/cli/README.md
- Commands section: Add the CLI command under the appropriate group heading (or add a new heading for a new group).
- MCP Tools table: Add a row
| \` | |` in the logical group.
3. packages/mcp-server/README.md
Add a row to the relevant tools table (or a new table + heading for a new category):
| `<mcp_name>` | <One-sentence description.> |
4. packages/phantom-openclaw-plugin/README.md
Add a full tool entry in the Available Tools section following the existing pattern:
### `<mcp_name>`
<Two-sentence description: what it does and when to use it.>
**Parameters:**
- `<param>` (<type>, required/optional): <description>
**Example:**
\`\`\`json
{ "<param>": "<value>" }
\`\`\`
**Response:** `{ <key fields> }`
Reference: context object
context.logger; // Logger — .info(), .warn(), .error(), .debug()
context.apiClient; // PhantomApiClient — Phantom REST endpoints
context.manager.getClient(); // PhantomClient — KMS signing, wallet ops
context.manager.getSession(); // { walletId, organizationId }
context.manager.isInitialized(); // boolean — false if not yet authed
A session is initialized on first use (triggering the browser auth flow) and persisted automatically. The createAction wrapper handles AUTH_EXPIRED by resetting the session and surfacing a clear error — no manual handling needed.
Reference: commonly used helpers
| Helper | Import path | When to use |
|---|---|---|
getSolanaAddress(context, walletId, derivationIndex) |
../utils/solana.js |
Derive Solana public key |
getEthereumAddress(context, walletId, derivationIndex) |
../utils/evm.js |
Derive EVM address |
createPerpsClient(context, walletId, derivationIndex?) |
../utils/perps.js |
All Hyperliquid perps ops |
normalizeNetworkId(id) |
../utils/network.js |
Normalise CAIP-2 chain IDs |
parseBaseUnitAmount(amount) |
../utils/amount.js |
String/number → bigint base units |
parseUiAmount(amount, decimals) |
../utils/amount.js |
UI units → bigint base units |
runSimulation(body, context) |
../utils/simulation.js |
Simulate before submitting |
WalletIdSchema |
../utils/schemas.js |
Optional wallet ID input field |
DerivationIndexSchema |
../utils/schemas.js |
Optional HD index input field |
Caip2ChainIdSchema |
../utils/schemas.js |
CAIP-2 chain ID input field |