Hand your agent a goal and watch the whole stack run itself
Run an autonomous Solana wallet workflow like a background worker: inspect state, act within policy, recover from failure and log every decision.
Hand your agent a goal and watch the whole stack run itself
“Make sure the savings wallet holds at least 0.2 SOL.” That one sentence is the only instruction you are going to give today. No recipient address in the prompt, no amount, no list of steps. Your agent will have to check two balances, do the arithmetic, decide whether a transfer is needed, request it through the policy layer you built on Day 95, wait for confirmation, check the balances again, and then tell you what it did. Every piece of that already exists in your project. Today you connect them and take your hands off the keyboard.
This is also the day the type on the label matters: this is an Experiment, not a Build. In Web2 terms, you have finished writing the service and now you are doing what a good engineer does before trusting a new background worker in production: run it under normal conditions, then under hostile conditions, and read the logs. Language models are non-deterministic, so the same goal can produce different tool-call sequences on different runs. That is exactly the property you want to observe and get comfortable with, because the policy layer, not the prompt, is what makes the system safe anyway.
The challenge
You will consolidate this week’s pieces into a single script: two read/write tools, a deny-by-default policy, an agent loop with a turn limit, and a structured run log written to disk. Then you will run three trials: a normal run, a run where the policy fights back, and a run with an impossible goal.
What you’ll need
- A terminal and your code editor
-
Node.js 20 or later. The script is plain ESM JavaScript, the same as your Day 93 and Day 95 files, so you run it with
nodeand no build step - The @anthropic-ai/sdk and @anchor-lang/core npm packages
- Your
ANTHROPIC_API_KEYenvironment variable from Day 92 - The devnet wallet file your agent has been signing with since Day 93 (called
agent-wallet.jsonbelow; adjust the filename if yours differs) - The Solana CLI from the early arcs, for creating a second wallet and topping up devnet SOL
Steps
1. Set up the project. Work in the same folder you used on Days 93 and 95, or make a fresh one. Install the two dependencies:
npm install @anthropic-ai/sdk @anchor-lang/core
2. Create the savings wallet and fund the operating wallet. The workflow needs two accounts: the operating wallet your agent spends from, and a savings wallet it deposits into.
solana-keygen new --no-bip39-passphrase --outfile savings-wallet.json
solana address -k savings-wallet.json
solana airdrop 1 $(solana address -k agent-wallet.json) --url devnet
If the CLI airdrop is rate limited, use the web faucet at faucet.solana.com instead. One devnet SOL in the operating wallet is plenty for all three trials.
3. Write the workflow script. Create agent-workflow.mjs. This is the whole stack in one file: tools, policy, agent loop, and run log. Read the policy object and the loop carefully before running it; those two blocks are where all of today’s experiments happen.
import Anthropic from "@anthropic-ai/sdk";
import { web3 } from "@anchor-lang/core";
import fs from "fs";
const {
Connection,
Keypair,
PublicKey,
LAMPORTS_PER_SOL,
SystemProgram,
Transaction,
sendAndConfirmTransaction,
} = web3;
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const anthropic = new Anthropic();
function loadWallet(path) {
return Keypair.fromSecretKey(
Uint8Array.from(JSON.parse(fs.readFileSync(path, "utf8")))
);
}
const operating = loadWallet("agent-wallet.json");
const savings = loadWallet("savings-wallet.json");
// ---- The goal: the only instruction the agent gets ----
const GOAL = `Make sure the savings wallet (${savings.publicKey.toBase58()}) holds at least 0.2 SOL. Check balances before moving anything, move only what is needed from the operating wallet, and verify the final balances before you finish.`;
// ---- The policy layer: deny by default, hard numeric caps ----
const POLICY = {
allowedRecipients: [savings.publicKey.toBase58()],
maxLamportsPerTransfer: 0.25 * LAMPORTS_PER_SOL,
maxLamportsPerRun: 0.5 * LAMPORTS_PER_SOL,
};
let spentThisRun = 0;
function checkPolicy(to, lamports) {
if (!POLICY.allowedRecipients.includes(to)) {
return { allowed: false, reason: `recipient ${to} is not on the allowlist` };
}
if (lamports > POLICY.maxLamportsPerTransfer) {
return {
allowed: false,
reason: `${lamports} lamports exceeds the per-transfer cap of ${POLICY.maxLamportsPerTransfer}`,
};
}
if (spentThisRun + lamports > POLICY.maxLamportsPerRun) {
return {
allowed: false,
reason: `this transfer would push total spend past the per-run cap of ${POLICY.maxLamportsPerRun} lamports`,
};
}
return { allowed: true, reason: "within policy" };
}
// ---- The run log: every decision, on disk ----
let currentTurn = 0;
const runLog = [];
function logEvent(event, detail) {
runLog.push({ turn: currentTurn, event, detail });
console.log(`[turn ${currentTurn}] ${event}: ${JSON.stringify(detail)}`);
}
// ---- The tools the model can call ----
const tools = [
{
name: "get_balance",
description: "Get the current balance of a Solana devnet account in lamports.",
input_schema: {
type: "object",
properties: {
address: { type: "string", description: "Base58 account address" },
},
required: ["address"],
},
},
{
name: "transfer_sol",
description:
"Transfer lamports from the operating wallet to a recipient. Every transfer is checked against a policy before signing. A denial is not an error: adjust your plan or stop.",
input_schema: {
type: "object",
properties: {
to: { type: "string", description: "Base58 recipient address" },
lamports: { type: "number", description: "Amount in lamports" },
},
required: ["to", "lamports"],
},
},
];
async function runTool(name, input) {
if (name === "get_balance") {
const lamports = await connection.getBalance(new PublicKey(input.address));
return { address: input.address, lamports };
}
if (name === "transfer_sol") {
const verdict = checkPolicy(input.to, input.lamports);
logEvent("policy_check", { ...input, ...verdict });
if (!verdict.allowed) {
return { status: "denied", reason: verdict.reason };
}
const tx = new Transaction().add(
SystemProgram.transfer({
fromPubkey: operating.publicKey,
toPubkey: new PublicKey(input.to),
lamports: input.lamports,
})
);
let signature;
try {
signature = await sendAndConfirmTransaction(connection, tx, [operating]);
} catch (err) {
// A failed send is a normal outcome the agent should reason about, not a crash.
// The usual cause is an operating wallet with too little SOL left.
const reason = err?.message ?? String(err);
logEvent("transfer_failed", { reason });
return { status: "failed", reason };
}
spentThisRun += input.lamports;
return { status: "confirmed", signature };
}
return { error: `unknown tool: ${name}` };
}
// ---- The agent loop: run until the model stops or we hit the turn limit ----
const MAX_TURNS = 12;
const SYSTEM = `You are a workflow agent managing Solana devnet wallets.
Operating wallet: ${operating.publicKey.toBase58()}
Savings wallet: ${savings.publicKey.toBase58()}
Amounts are in lamports; 1 SOL = ${LAMPORTS_PER_SOL} lamports.
Use your tools to accomplish the goal. A policy denial means the action is not allowed; adjust your plan or stop. When the goal is met, or you conclude it cannot be met, stop and give a short honest report of what you did.`;
async function main() {
const messages = [{ role: "user", content: GOAL }];
for (currentTurn = 1; currentTurn <= MAX_TURNS; currentTurn++) {
const response = await anthropic.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
system: SYSTEM,
tools,
messages,
});
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason !== "tool_use") {
const report = response.content
.map((block) => (block.type === "text" ? block.text : ""))
.join("");
logEvent("final_report", report);
break;
}
const toolResults = [];
for (const block of response.content) {
if (block.type === "tool_use") {
logEvent("tool_call", { tool: block.name, input: block.input });
const output = await runTool(block.name, block.input);
logEvent("tool_result", output);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: JSON.stringify(output),
});
}
}
messages.push({ role: "user", content: toolResults });
}
fs.writeFileSync("run-log.json", JSON.stringify(runLog, null, 2));
console.log(`Run complete. ${runLog.length} events written to run-log.json`);
}
main();
4. Trial 1: the baseline run. Execute the script (see Run it below) and watch the log lines scroll. You should see the agent read both balances, compute the shortfall, make one policy-approved transfer, re-read the savings balance, and finish with a report. Paste the transfer signature into Solana Explorer on devnet to confirm it is real. Then run the script a second time without changing anything. The goal is already satisfied, so a well-behaved agent should make zero transfers and say so. Note in your log whether it actually did.
5. Trial 2: make the policy fight back. Now create a conflict between what the goal needs and what a single transfer is allowed to move. Change two lines:
- In
POLICY, setmaxLamportsPerTransferto0.05 * LAMPORTS_PER_SOL - In
GOAL, change the target from 0.2 SOL to 0.4 SOL
The savings wallet holds 0.2 SOL from Trial 1, so the agent needs to move 0.2 more, but no single transfer over 0.05 SOL will be signed. There are two policy-compliant outcomes: the agent splits the work into several small transfers, or it hits a denial and gives up. Both are legitimate model behavior, and which one you get is the experiment. Run it two or three times and record what happens each time. Notice something important in your policy design while you watch: the per-transfer cap alone did not stop the agent from moving 0.2 SOL, it only slowed it down. The maxLamportsPerRun cap is what actually bounds total spend. Rate limits and request limits protect against different things, exactly like in a Web2 API.
6. Trial 3: give it an impossible goal. Change the goal target to 5 SOL and run again. The operating wallet does not hold 5 SOL and the per-run cap would block it anyway. What you are testing now is failure behavior: does the agent recognize the situation from the balances and denials and report honestly that the goal cannot be met, or does it keep issuing doomed transfer attempts until MAX_TURNS cuts it off? Either way, notice that nothing bad can happen to your funds: the policy layer and the turn limit are doing their jobs regardless of what the model decides. That is the entire argument for Day 95’s architecture, demonstrated live.
7. Write down your observations. Open the run-log.json from each trial and record, for each run: how many turns it used, how many tool calls it made, how many policy denials it hit, how many sends failed on chain (transfer_failed, usually an operating wallet that ran low), how many transfers confirmed, and whether the final report matched what actually happened on chain. Keep these notes; you will want them for tomorrow’s Document day.
Run it
export ANTHROPIC_API_KEY=your-key-here
node agent-workflow.mjs
cat run-log.json
What just happened
You ran an autonomous multi-step workflow against a public blockchain, three times, under three different conditions, and you have the logs to prove it. Structurally, this is something you already know from Web2: a background worker that wakes up, checks state, reconciles it toward a target, and records what it did. The difference is that the “reconciliation logic” here is a language model reasoning over tool results instead of an if-statement you wrote, which is why the run behaves slightly differently every time. Your job shifted from writing the steps to designing the environment the steps happen in: which tools exist, what the policy signs, how many turns the loop allows, and what gets logged.
Trial 2 is the one worth sitting with. When the policy denied a large transfer and the agent responded by splitting it into small ones, neither your code nor the model did anything wrong; your policy simply allowed an outcome you may not have intended. This is the everyday reality of agentic systems: safety comes from the composition of constraints, not from any single rule, and the only way to find the gaps is to run experiments exactly like the one you just ran. Anthropic’s guide to building effective agents makes the same point from the builder’s side: simple, observable loops with clear tool contracts beat clever orchestration, because you can actually reason about what they will and will not do.
You also quietly built the observability layer that production agent systems live and die by. That run-log.json file, with every tool call, policy verdict, and transaction signature in order, is the agent-world equivalent of structured request logging, and it is what lets you answer “why did the wallet balance change” with evidence instead of a guess.
Resources
- Tool use with Claude: the official documentation for the tool-calling loop your script implements
- Building Effective AI Agents: Anthropic’s engineering guide to agent loop design and when to keep it simple
- @anthropic-ai/sdk on npm: the TypeScript SDK used for the agent loop
- Solana Explorer (devnet): verify every signature your agent produced
Submission
Submit a screenshot of your terminal showing a complete run, from the first tool_call log line through the agent’s final report.