Skip to content
Julian De Leon
Writing

Building a Coding Agent from Scratch

31 min read

I've been playing around with coding agents since they first showed up, back when "agent" mostly meant a chat window that pasted code and hoped for the best. At some point using them stopped being enough, and I wanted to know what was actually happening inside.

So I built one. nod is a coding agent written from scratch in TypeScript on Bun. It runs on the ChatGPT or Grok subscription I was already paying for, so there is no API key and no per-token bill quietly growing in the background.

This post shows how an agent like nod can be built. It starts from a loop that fits on one screen, and adds one piece at a time. Every piece exists because the simpler version fails at something, so each section starts with that failure. For scale, this is how far the loop grows:

[ CHANGES ]
  • bashtool
  • task_completetool
  • built-in tools17
  • permission modes3
  • providers2
lines of code~24,000

The first version of nod had two tools and a loop. The current one is what happens when you keep saying "just one more thing". The snippets below are simplified to fit on a page, and the full source is on GitHub.

Starting with a loop#

Before any architecture, it helps to be precise about what an agent is. It is less than most people expect.

What an agent is#

A language model can't run code, read files, or touch your keyboard. It can only produce text. Think of it as a very well-read colleague on the phone: they tell you what to type, and you read back what the screen says.

Tool calling makes that conversation structured. Along with the conversation, the program sends the model a list of tools it may use. The model either answers in text, or replies with a request like "call shell with these arguments". The program runs the request and sends the result back.

[ LOOP ]
messages
model
model
tool calls
tool calls
results
results
messages

That's the whole trick. Every coding agent you've used, however polished, has this loop somewhere in the middle. The loop ends when the model replies with text and asks for no tools.

Messages#

The model has no memory between calls. Everything it knows about the task is in the list of messages sent each time. A minimal agent needs three kinds:

type ToolCall = { id: string; name: string; arguments: string };
type Message =
  | { role: "user"; content: string }
  | { role: "assistant"; content: string; toolCalls: ToolCall[] }
  | { role: "tool"; toolCallId: string; content: string };
type Reply = { content: string; toolCalls: ToolCall[] };
type Model = (messages: Message[]) => Promise<Reply>;

Two details matter. The arguments of a tool call are a JSON string, not an object, because that is what models produce. And every tool result carries the id of the call it answers, so the model can match outputs to requests when it asked for several at once.

[ MESSAGES ]
  1. userthe task
  2. assistanttool calls
  3. toolresults
  4. assistantanswer

Model is a plain function type on purpose. Anything that takes messages and returns a reply can be the model: a real API, or a script written by hand.

The loop#

The agent itself needs one tool, a shell, and a loop that connects the model to it:

async function shell(command: string): Promise<string> {
  const proc = Bun.spawn(["bash", "-c", command], { stderr: "pipe" });
  const out = await new Response(proc.stdout).text();
  const err = await new Response(proc.stderr).text();
  return `exit ${await proc.exited}\n${out}${err}`;
}

async function agent(model: Model, task: string): Promise<string> {
  const messages: Message[] = [{ role: "user", content: task }];
  while (true) {
    const reply = await model(messages);
    messages.push({ role: "assistant", ...reply });
    if (reply.toolCalls.length === 0) return reply.content;
    for (const call of reply.toolCalls) {
      const { command } = JSON.parse(call.arguments);
      console.log(`● ${command}`);
      const result = await shell(command);
      console.log(result.trim());
      messages.push({ role: "tool", toolCallId: call.id, content: result });
    }
  }
}

The list starts with the task. Each pass asks the model for a reply and appends it, so the model will see its own requests next time. No tool calls means the model is done. Otherwise every call runs, and its output goes back into the list with the matching id.

The shell returns the exit code together with the output. That is deliberate: a model that can't see exit 1 will happily report that the tests passed.

Running it#

Testing the loop doesn't need a real model. A scripted model returns a fixed reply each time it is called, which makes the run repeatable and costs nothing:

const call = (id: string, command: string): Reply => ({
  content: "",
  toolCalls: [{ id, name: "shell", arguments: JSON.stringify({ command }) }],
});

const script: Reply[] = [
  call("1", "cat src/math.ts"),
  call("2", "sed -i '' s/-/+/ src/math.ts"),
  call("3", "bun test 2>&1 | grep pass"),
  { content: "Fixed: add() now returns a + b.", toolCalls: [] },
];
const scripted: Model = async () => script.shift()!;

console.log(await agent(scripted, "fix add() and run the tests"));

The repo has one file, src/math.ts, where add() subtracts, and one test. This is the real output, trimmed to the lines that matter:

[ PART 1 ]
$ bun run agent.ts
● cat src/math.ts
exit 0
  return a - b;
● sed -i '' s/-/+/ src/math.ts
exit 0
● bun test 2>&1 | grep pass
 1 pass
Fixed: add() now returns a + b.

It works. Swap the script for a real model and you have a coding agent in about 30 lines. nod's own test suite uses the same scripted-model trick.

What the loop doesn't handle#

This version breaks quickly with real users. Each weakness below is what one of the following sections solves.

[ GAPS ]
scripted model
real model
only a shell
tools
runs anything
permissions
crashes, forgets
recovery
no context
context
untested
evals

Some of these fail loudly. If the model sends invalid JSON, JSON.parse throws and the whole agent dies. Others fail quietly: npm run dev never exits, so await proc.exited waits forever. The rest of nod is the answer to this list, one line at a time.

Connecting a real model#

The scripted model proved the loop works. Replacing it with a real one raises three questions: what the interface looks like, how to talk to the provider, and how to sign in.

The model contract#

The minimal Model returned the whole reply at once. Real models stream: text arrives a few tokens at a time, and a user staring at a blank screen for 40 seconds assumes the program froze. So nod's model interface is an async generator that yields events while the reply is written and returns the finished completion at the end:

type LLM = {
  stream(
    messages: Message[],
    tools: ToolSpec[],
    signal?: AbortSignal,
    options?: StreamOptions,
  ): AsyncGenerator<StreamEvent, Completion>;
};

The signal lets the user cancel a request halfway through. The loop only ever sees this interface, which is what makes adding providers easy: it has no idea who is on the other end, and it doesn't need to.

One API, two providers#

Both ChatGPT and Grok speak the OpenAI Responses API, so one file, responses.ts, builds every request. The conversion is mostly renaming. System messages become the instructions field, and every other message becomes an input item:

[ REQUEST ]
system
instructions
assistant
function_call
tool
call output

The request always sets stream: true and store: false, and reasoning from earlier replies is never sent back. What differs between the providers is small enough for one table:

[ BACKENDS ]
codexgrok
  • hostchatgpt.comgrok.com
  • reasoningsummary autodefault
  • web searchnativeprobe first
  • defaultgpt-5.6-lunafirst listed

It's nice when two companies agree on a format. It saved me from writing the same parser twice.

Reading the stream#

The response comes back as server-sent events: blocks of data: lines separated by blank lines. The parser reads each block as JSON and turns the event types the loop cares about into stream events.

[ EVENTS ]
text delta
text
reasoning delta
reasoning
function_call
tool call
completed
completion

Network errors are handled here too, before the loop sees them. A 401 refreshes the token and retries once. A 429 or 5xx retries up to 4 times, waiting 2, 4, 8, and 16 seconds. Most hiccups never reach the loop at all.

Signing in#

An API key is a string in an environment variable. A subscription is a person with a browser, so nod signs in the way the Codex CLI and Grok CLI do: OAuth with PKCE and a small local server that receives the callback.

[ OAUTH ]
  1. pkcemake verifier
  2. listenlocal server
  3. browseruser signs in
  4. callbackcode, state
  5. exchangeget tokens
  6. saveauth file

PKCE deserves a short explanation. nod makes a random secret, sends only its hash when opening the browser, and reveals the secret when trading the code for tokens. Someone who intercepts the code can't use it without the secret. The tokens are then stored and refreshed automatically:

[ TOKENS ]
stored in
~/.nod
file mode
0600
refresh
60s early
rejected
new login

With a real model connected, the agent can think. The next problem is what it is allowed to do.

Tools#

The minimal agent had one tool, and a shell can do everything. So why does nod have 17? Because "can do everything" includes printing a million lines, hanging forever, and replacing the wrong line with sed.

Why not only a shell#

Separate tools give the loop information a shell command hides. A read_file call can't modify anything, so it can skip approval and run in parallel with other reads. An edit_file call can show a diff before it happens. A shell command is just a string.

[ TOOLS ]
  • files
  • read_file400 lines
  • glob_files100 paths
  • grep_filesliteral text
  • edit_fileexact match
  • write_filewhole file
  • run
  • shellbackground
  • subagentdelegate
  • outside
  • web_fetchpublic urls
  • web_searchsearch
  • visionimages
  • context
  • skillSKILL.md
  • capability_searchfind tools
  • read_tool_resultlarge output
  • ask_user_questionoptions
Tree with 18 nodes

The remaining three, install_skill, mcp_select_tool, and mcp_features, manage skills and MCP servers, covered in the context section.

Anatomy of a tool#

In the minimal agent a tool was a function. In nod it is a ToolSpec, an object that describes the tool to the model and to the loop:

type ToolSpec<I> = {
  name: string;
  description: string;
  parameters: Record<string, unknown>; // JSON Schema
  decode(args: unknown): { ok: true; input: I } | { ok: false; failure: string };
  label?(input: I): string;
  targets?(input: I): PermissionTarget[];
  prepare?(input: I): Promise<Preparation>;
  readsOnly?(input: I): boolean;
  call(input: I, ctx: ToolContext, prepared?: Preparation): Promise<ToolResult>;
};

The model only sees the first three fields. The rest are for the loop, which uses them in a fixed order before and during the call:

[ TOOLSPEC ]
  1. decodecheck args
  2. labeldisplay text
  3. targetspaths, hosts
  4. preparebuild diff
  5. readsOnlysafe to read
  6. callrun it

targets and readsOnly exist for the permission system. prepare exists so the user can approve a change before it happens.

Validating calls#

In the minimal agent, invalid JSON made JSON.parse throw and killed the loop. In nod, a function called admit checks every call before it runs. The rule is simple: a mistake by the model is never an exception. It becomes the tool result, and the model reads it on the next step.

[ ADMIT ]
unknown name
error result
invalid json
error result
bad field
error result
valid
execute

Each error is JSON with a type, a message, and a suggestion such as reissuing the call with valid JSON. Models are surprisingly good at reading their own error messages. Better than most of us, honestly.

Running calls in parallel#

Models like to ask for several things at once, usually a handful of files to read. When a reply has several calls, the leading run of read-only calls starts together with Promise.allSettled, and the rest run one by one. The timing below is illustrative:

[ BATCH ]
  • read_file
  • grep_files
  • edit_file
  • shell
replydone

Results are appended in the original order, even when a later read finishes first, so the history matches what the model asked for. Writes always wait their turn: parallel edits are how you get a merge conflict with yourself.

Editing files safely#

The minimal agent edited with sed, which replaces whatever matches, however many times it matches. edit_file is stricter. It replaces one exact string, and if that string is missing or appears more than once, the call fails and says how many matches it found.

[ EDIT ]
edit_filewrite_file
  • inputold and newfull content
  • needs file
  • unique match
  • shows diff
  • atomic write

There is a subtle race here. The diff is built in prepare, then the user takes a few seconds to approve it. If the file changed in those seconds, writing the old plan would destroy the new content. So nod plans the edit a second time and only writes if both plans match:

[ MUTATION ]
  1. preparebuild diff
  2. approveuser or model
  3. re-plandiff again
  4. comparesame diff?
  5. undokeep old copy
  6. writetemp, rename

The last step writes to a temporary file and renames it, so a crash never leaves a half-written file behind.

A shell that doesn't hang#

Back to npm run dev, the command that never exits. nod's shell runs every command in the background. run starts the process and waits up to 30 seconds. If it finished, the model gets the output and exit code. If not, the model gets a session id and can check again later with interact.

[ SHELL ]
  1. runspawn process
  2. yieldwait 30s
  3. interactread output
  4. stopTERM, KILL

Commands also run with TERM=dumb, NO_COLOR=1, CI=1, and PAGER=cat, so git log doesn't open a pager and wait for a keypress nobody will make. The limits keep one command from taking over the session:

[ LIMITS ]
default wait
30 seconds
interact wait
5s to 300s
live commands
64
inline output
16 KiB
full output
log handle

A malformed shell request is not run. The error lists the problems and, when it can, includes a retry_with field with a corrected request. The model usually takes the hint.

Output that doesn't fit#

In the minimal agent, cat on a big file would push the whole file into the messages and send it again on every later request. nod stores any tool result over 16 KiB in the session's results/ folder and gives the model a short preview and a handle instead.

[ RESULT ]
over 16 KiB
results/
model gets
4 KiB preview
handle
read_tool_result

With read_tool_result, the model can read a byte range or ask for the lines containing a string. One of nod's evals hides a single error in a 1.1 MiB log, and this is how much of that log reaches the conversation:

[ LOG ]
  1. app.log1.1 MiB
  2. inline limit16 KiB1%
  3. preview4 KiB0%

So far, every tool just runs when the model asks. The next part decides whether it should.

Permissions#

Giving a model a shell is fine right up until it decides rm -rf is a cleanup step. The minimal agent runs anything, so nod puts a gate between the model's request and the tool.

Permission modes#

Different users want different amounts of control, so nod has three modes. ask prompts before every change, full-access checks nothing, and auto, the default, approves routine work and sends the rest to a reviewer model.

[ MODES ]
askautofull
  • read files
  • edit in repoprompt
  • bun testprompt
  • other commandpromptreview
  • rules apply

The decision itself is a sequence of checks, and the first one that decides wins, much like firewall rules. User rules in ~/.nod/settings.json load first and workspace rules last, so a project can tighten or loosen them.

[ DECIDE ]
  1. full accessallow all
  2. deny rulerefuse
  3. allow ruleor grant
  4. read-onlysafe command
  5. reversibleauto only
  6. otherwisereview, prompt

Classifying commands#

Auto mode depends on knowing which commands are safe, which means reading them before running them. nod splits a command into segments and checks each one against known lists. It only classifies static commands: no variables, no substitutions, no newlines, because those can hide what actually runs.

[ COMMANDS ]
read-onlyreversible
  • gitstatus, diffadd, commit
  • packagesnpm lsbun install
  • checksbun test, tsc
  • join withpipes&&
  • auto modeallowallow in repo

Anything with push, publish, deploy, or release is never reversible. In auto mode, edits to files like .git/hooks, .ssh/config, or .zshrc always go to review. git push is technically reversible if you're brave enough to force-push. nod is not that brave.

The reviewer#

Lists can't cover every command, so in auto mode the unclear cases go to a second model call. On a ChatGPT subscription it uses gpt-5.4-mini when available, and it must answer with exactly one tool call: clear or caution.

[ REVIEW ]
pending action
reviewer
clear
run
caution
held

The reviewer's job is narrow on purpose. It looks for prompt injection, credential theft, or hidden execution, not for whether an action is destructive. If it fails or times out, the action is held, never allowed:

[ REVIEWER ]
context
16 KiB
timeout
30 seconds
per turn
2 reviews
on failure
held

A held or denied call becomes a JSON error in the tool result, just like the admission errors. The reviewer is a second opinion, not a babysitter: whether deleting a folder is a good idea is still between you and the main model.

Surviving failures#

The minimal agent keeps everything in one array in memory. One network error kills it, pressing Ctrl+C loses the conversation, and a long session eventually stops fitting in the model's context. Each needs its own fix.

When the provider fails#

Subscription backends are not public APIs, and every now and then they remind you. The provider layer already retries simple HTTP errors. The loop adds a second layer: it classifies the failure and picks a strategy, with at most 10 attempts per turn.

[ FAILURES ]
429 or 5xx
wait, retry
stream timeout
pause
auth error
stop
partial text
continue

The wait uses exponential backoff: each failure of the same kind waits longer, and any success resets it. This keeps a struggling server from being hit by every client retrying at once. A Retry-After header overrides the wait, capped at 30 seconds.

[ BACKOFF ]
  1. 1st250 ms
  2. 2nd1 s
  3. 3rd2 s
  4. 4th4 s
  5. 5th8 s
  6. 6th16 s
  7. 7th+30 s

When a paused turn is resumed with /continue, the loop tells the model to restart its response from the completed tool results without repeating them.

When the user interrupts#

Sometimes the interruption comes from the user, pressing esc because the model is heading somewhere it shouldn't. That leaves a problem: the API requires every function call in the history to have an output, and the cancelled call has none. So the history is repaired before the next request:

[ REPLAY ]
  1. userthe prompt
  2. stepsanswered calls
  3. assistantinterrupted
  4. userturn_aborted

Calls without a result are dropped. The final turn_aborted notice tells the model that tools may have partially run and that it should not continue the old request unless the user asks.

When the context fills up#

Every request sends the whole history, and models have a limit. Long sessions eventually run out of room, and every agent has to decide what to forget. nod has no tokenizer, so it estimates tokens as bytes divided by 4 and corrects that ratio after each request using the real count the provider returns.

[ BUDGET ]
  • window400k
  • max output-128k
  • headroom-54k
  • compact at218k
window 400k, max output -128k, headroom -54k, compact at 218k

The chart uses gpt-5.6-sol. Part of the window is reserved for the model's reply, which leaves 272k usable. Compaction starts at 80% of that. The summary may use up to 10%, and the newest turns, kept word for word, up to 5%. When a request would cross the line, the oldest whole turns are summarized and the summary replaces them:

[ COMPACT ]
old turns
summary
recent turns
unchanged
both
new history

There is a security detail here. The summary prompt tells the model to describe, not obey, what it reads, and the summary ends with a rule that its text is not permission. Without that rule, a summary that says "the user approved everything" could quietly become true.

Saving sessions#

Compacted or not, a conversation is only useful if it survives closing the terminal. nod saves each session in its own folder, and the main file is an append-only event log.

[ SESSION ]
  • sessions/<id>
  • session.jsonmetadata
  • events.jsonlevent log
  • recovery.jsonpaused turn
  • session.lockowning pid
  • results/large outputs
Tree with 6 nodes

Why append-only? Because a crash in the middle of a write can only damage the last line, and the reader skips an unfinished last line. Resuming a session replays the events in order to rebuild the conversation:

[ RESUME ]
  1. useropen turn
  2. tool_calladd to step
  3. tool_resultmatch call
  4. completedclose turn
  5. checkpointuse summary

A checkpoint event records a compaction, so a resumed session starts from the summary instead of the full history.

Back to the loop#

With these pieces in place, nod's real loop can be compared with the minimal one. The idea is identical. What changed is everything around each line:

[ REVISITED ]
minimalnod
  • stops whenno callsno calls
  • streaming
  • bad jsoncrasherror result
  • approval
  • retries
  • compaction
  • saved

One pass through nod's loop is called a step: one model request plus the tool calls it produced. A step does these things, in order:

[ STEP ]
  1. steeringqueued input
  2. compactif near limit
  3. requeststream reply
  4. admitparse calls
  5. executecheck, run
  6. resultsappend, repeat

"Steering" is a message the user typed while the agent was busy. It is added before the next request, so the model can change course without stopping.

How a turn ends#

The minimal loop had one exit: no tool calls. nod's loop can end a turn in four ways, and only one of them is a normal answer.

[ OUTCOMES ]
text, no calls
completed
esc pressed
interrupted
provider down
paused
auth or filter
failed

There are also guards against loops that go nowhere. Three steps in a row where every call is malformed end the turn, and so do three steps of invalid shell requests. A step limit exists but is off by default.

[ GUARDS ]
malformed json
3 steps
invalid shell
3 steps
step limit
off
silent steps
summarize

The last guard handles a quirk. If the model runs tools for two steps without writing anything and then stops with no text, the loop asks "Summarize what you just did." Models sometimes do a lot of work and then leave without a word, so the loop politely asks them to explain themselves.

Context#

The agent is now safe and durable, but it knows nothing about the project it's working in. This part covers what goes into the model's context and how nod reaches beyond its built-in tools.

The system message#

The system message is the first thing the model reads. nod rebuilds it before every request from four parts, from the most general to the most specific:

[ SYSTEM ]
  1. promptbase rules
  2. agents.mdproject rules
  3. skillsskill catalog
  4. runtimecwd, git, date

The runtime part is recomputed each time, so the model always sees the current branch and number of changed files. Project rules come from AGENTS.md files, collected from the widest scope to the narrowest, and the narrowest wins on a conflict:

[ AGENTS.MD ]
  1. global~/.nod
  2. ancestorsparent dirs
  3. projectrepo root
  4. scopedtouched dirs

Each file is capped at 64 KiB, and all of them together at 128 KiB. Your repo's AGENTS.md can have opinions, but only 64 KiB of them.

Subagents#

Some tasks need a lot of reading that the main conversation doesn't need to keep, like "find where this function is used". The subagent tool runs such a task in a separate loop with its own history, and only the final answer comes back to the parent.

[ SUBAGENT ]
parentchild
  • historysharedfresh
  • rules
  • grants
  • can prompt
  • subagents

A child has no subagent tool, so nesting stops at one level. One level turned out to be enough. Agents spawning agents spawning agents makes a fun demo and an expensive afternoon.

Skills and MCP#

Everything so far is built in. Skills and MCP are how nod picks up things I didn't write, and both follow the same idea: advertise a little, load the rest on demand. A skill is a folder with a SKILL.md. Only its name and description go in the system prompt, and the model loads the full file with the skill tool when it needs it.

[ SKILLS ]
  • discovery
  • <repo>/skillsand parent dirs
  • .claude/skillsrepo and home
  • .agents/skillsrepo and home
  • ~/.nod/skillsinstalled
Tree with 5 nodes

MCP servers can expose dozens of tools, and every tool schema sent to the model costs context on every request. So nod sends none of them until the model searches for one:

[ MCP ]
capability_search
matches
select tool
tool list
next step
tool available

MCP tools always go through the permission check, and servers from a project's .mcp.json don't start until the user trusts them.

One loop, many interfaces#

The last design decision is where the loop's output goes. The minimal agent called console.log directly. nod's loop never prints anything. It yields events, and each interface decides what to do with them:

[ HOSTS ]
AgentLoop
events
events
nod ask
events
ink shell
events
acp
events
sdk

nod ask prints text for scripts, or one JSON line with --json. The Ink shell is the interactive terminal app. nod acp speaks the Agent Client Protocol for editors. The core never knows which one it's talking to, and that separation is what makes all four possible.

Verifying it works#

The last weakness on the list was "untested". An agent that works once on my machine is a demo, not a tool.

A real run#

This is the same task as the minimal agent's, with a real model. This run used gpt-5.6-sol in auto mode. It asked for no approvals, because the edit was inside the repo and bun test is on the reversible list.

[ RUN ]
$ nod ask "fix add(), run tests"
● glob_files **/*
● grep_files add(
● read_file src/math.ts
● read_file src/math.test.ts
● edit_file src/math.ts
● read_file src/math.ts
● read_file src/math.test.ts
● shell.run bun test
1 passed, 0 failed

Unlike the scripted run, the model explored before editing, used dedicated tools instead of cat and sed, and read the file again after the edit, because the loop asks for a review after any step that writes a file. The change itself was one character:

[ MATH.TS ]
  • return a - b;
  • return a + b;
bun test1 pass

The output above is trimmed to the tool calls and the result. A separate run with --json reported 5 steps, 8 tool calls, and 47,460 input tokens. That's about 47,000 tokens to fix a minus sign. They add up because each of the 5 requests sends the system prompt, the tool definitions, and the conversation so far.

Tests and evals#

Testing an agent has two halves. The loop's logic can be tested exactly, and the model's behavior can only be measured.

[ TESTS ]
  • 433

    tests

  • 77

    files

  • 8

    evals

The tests use scripted models, the same trick as the minimal agent, to make the provider fail, stream partial text, or send malformed calls on command. The evals run a real model on 8 small repos and check the result, for example that the tests pass and the test file is unchanged:

[ EVALS ]
  1. fix-bugoff-by-one
  2. implementwrite slugify
  3. renamerename
  4. answerread a value
  5. search-log20k-line log
  6. read-onlychange nothing
  7. featureStack.peek
  8. configadd a script

Checks can be wrong too, so a unit test proves that every check fails on the untouched repo and passes with a reference solution.

Where the code went#

The agent started as 30 lines and a scripted model. Each weakness turned into a component, and the loop in the middle never changed its shape. This is where the 24,000 lines ended up:

[ LINES ]
  1. ui4,728
  2. tools3,387
  3. mcp3,064
  4. cli1,926
  5. permissions1,524
  6. session1,436
  7. agent loop1,374
  8. acp1,221
  9. providers952
  10. sdk916

The agent loop is one of the smaller modules. That's the main lesson from building it: the loop is easy, and the engineering is in tools, permissions, and recovery. That's also why the code is organized around a small core that never imports a provider or a UI:

[ SRC ]
  • src
  • core/agentthe loop
  • core/toolstool specs
  • core/permissionsapproval
  • core/sessionevent log
  • core/mcpmcp client
  • providerscodex, grok
  • clicommands
  • uiink shell
  • acpeditors
  • sdkembedding
Tree with 11 nodes

The full source of nod is on GitHub.