@trylooper/sdk
v0.2.0
Published
Looper Agent SDK: drive the Looper agent (the looper CLI) from Node.js — streaming events, tool approvals, sessions shared with the Looper app.
Maintainers
Readme
Looper Agent SDK
Drive the Looper Agent from Node.js. It is the same agent as the Looper desktop
app and the looper CLI: its
tools, sub-agents, Auto approvals, compaction, /goal, checkpoints and MCP.
The SDK runs the native looper binary (installed with it) and speaks its
stream-json protocol.
npm i @trylooper/sdkSupported on macOS (arm64, x64) and Linux (x64, arm64), with Node 18 or newer.
The binary comes with the SDK; you do not need to install the CLI. The SDK looks
for it in options.pathToLooperExecutable, then $LOOPER_BIN, then the
platform package installed with it, then a looper on the PATH (for example
one from brew install luizguil99/tap/looper).
Quick start
import { query } from '@trylooper/sdk'
for await (const msg of query({
prompt: 'Fix the failing test',
options: { cwd: '/path/to/repo', model: 'anthropic/claude-sonnet-5-5', permissionMode: 'acceptEdits' },
})) {
if (msg.type === 'event' && msg.event.type === 'text') process.stdout.write(msg.event.content)
if (msg.type === 'result') console.log('\n', msg.subtype, msg.usage)
}Provider keys come from looper auth set … (shared with the Looper app),
ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY, or
options.secrets. Secrets are passed through a file that the CLI deletes, so
they never appear on the command line.
Live code and terminal streaming
Each tool card shows two live streams, as in the Looper app:
- Code. While the model is writing a
WriteorEditcall,tool_useevents with the sameidcarry the code written so far, about every 100 ms. - Terminal. A running
Shellcommand sends its stdout and stderr asbash_outputchunks.
For the code, there are two granularities:
- Default: drafts of the code so far, about every 100 ms. This is what the Looper app shows.
includePartialToolInput: true: every chunk the model writes arrives as it is generated, as atool_input_deltaevent with the raw JSON of the arguments. This works for any tool and matches the Claude Agent SDK'sinput_json_deltastream events withincludePartialMessages. With a real model, writing a 60-line file gave about 120 updates of ~16 characters, against ~20 drafts.
ToolCalls turns these events into updates. It decodes the raw JSON exactly,
escapes included, and gives the same code updates in both modes:
import { query, ToolCalls } from '@trylooper/sdk'
const tools = new ToolCalls()
for await (const msg of query({
prompt: 'Add a /health route and run the tests',
options: { cwd, includePartialToolInput: true },
})) {
for (const u of tools.push(msg)) {
switch (u.kind) {
case 'start': console.log(`\n▸ ${u.call.tool}`); break
case 'code': process.stdout.write(u.delta); break // u.field: contents | old_string | new_string
case 'input_delta': break // raw JSON chunk of any tool (u.partialJson)
case 'output': process.stdout.write(u.chunk); break // u.stream: stdout | stderr
case 'result': console.log(`\n✓ ${u.call.tool}`, u.result.isError ? 'failed' : ''); break
case 'file': console.log(`edited ${u.modification.filePath}`); break
}
}
}tools.get(id) returns the current state of a call: its merged input, its
status, the terminal output / outputText, the result (whose data has
the diff of a file tool) and the fileModification (content before and after).
Approvals
const q = query({
prompt: 'Clean the build folder',
options: {
cwd,
permissionMode: 'ask',
canUseTool: async (req, { signal }) =>
req.tool_name === 'Shell' && req.command?.startsWith('rm ')
? { behavior: 'deny', message: 'No deletions in CI' }
: { behavior: 'allow', scope: 'once' }, // scope: 'session' allows the tool for the session
},
})Without canUseTool, the permission mode decides alone: anything that would
ask is denied unless permissionMode is bypass. The SDK keeps the session's
input open until it is idle, so approvals from background sub-agents still
reach canUseTool after the turn's result.
Streaming input and control
async function* messages() {
yield 'Read the README'
yield { content: 'Now summarize it in Portuguese', model: 'anthropic/claude-haiku-5-5' }
}
const q = query({ prompt: messages(), options: { cwd } })
q.send({ content: 'also check the tests', priority: 'now' }) // steer the running turn
await q.interrupt() // stop the running turn; queued messages then run
await q.stop() // the app's Stop button: also drops queued messages
await q.setModel('chatgpt/gpt-6.1-sol'); await q.setPermissionMode('auto'); await q.setAgentMode('plan')
await q.listModels(); await q.getContextUsage(); await q.mcpStatus(); await q.listBackground()
await q.stopTask(taskId); await q.compact()
await q.checkpointPreview({ index: 2 }); await q.checkpointRestore({ index: 2 }); await q.checkpointRedo()
await q.resend({ index: 2 }, 'the edited text') // edit & resend (no text = retry); truncateMessages() alone cuts
await q.forkSession({ index: 3 }); await q.renameSession('Login fix')
await q.listFileModifications(); await q.acceptFileModifications(); await q.revertFileModifications(path)
await q.userInputResponse(card.id, { vars: [{ key: 'API_KEY', value }] }) // a user_input_request form (Looper.app running)
(await q.initializationResult()).commands // slash commands, tools, capabilities
q.close() // terminate nowA steer (priority: 'now') the turn never read comes back as a
user_steer_dropped event with its uuid. Without stop() the CLI then sends
it as its own turn; after stop() it does not: re-send it yourself if you
want it (the session no longer waits for its result).
Options: cwd, model, effort, agentMode (agent | plan | ask |
multitask), permissionMode (default | auto | ask | acceptEdits |
bypass), canUseTool, sessionId / continue (resume a chat, including one
started in the Looper app), appendSystemPrompt, allowedTools /
disallowedTools, includePartialToolInput, dbPath / standalone, background (wait | detach),
secrets, env, abortController, stderr, pathToLooperExecutable,
extraArgs.
Messages
The iterator yields:
system/init: the session, model, tools and capabilities;event: one agent event (textandreasoningdeltas,tool_use,tool_result,bash_output,file_modification,usage,approval_request,async_question,plan_approval_request, …);result: one per turn;system/task_startedandsystem/task_notification: background sub-agents and shells;system/turn_started: follow-up and resumed turns;system/status,compact_boundaryandgoal.
Every type is exported (LooperMessage, AgentEvent, EventOf<'tool_use'>,
…). They are generated from the protocol's JSON Schema. The helpers
isEvent(msg, 'text') and collectText(messages) cover common cases.
Docs: https://trylooper.app/cli
