npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

looptape

v0.1.0

Published

Record an AI agent once. Replay it without a model. Prove it told the truth. A tape format, a recording proxy, a deterministic player, and 37 checks for the ways agent loops lie.

Readme


Your agent lied to you last Tuesday, and your test suite was green.

It said "on it, I'll pull that together" and the run ended. It said "I can check the health and open incidents" to a user who had just asked it to. It said "I can't access the agent list from here" with an empty tool trace. It wrote "Saved a memory for next time" and no memory tool ran. It reported "all 327 resources healthy" off a number it remembered, while the one listing it actually made returned 40. It was shown the first 60 entries of a 214-entry list, nothing told it the list was cut, and it told the user the missing repo did not exist.

None of those are model bugs. They are loop bugs. The loop, or harness, is everything around the model: the code that calls it, reads the reply, decides whether that was a tool call or a final answer, runs the tool, feeds the result back, and goes again. A plain-text reply is the terminal state of every such loop, so whatever the model says last, nothing runs afterwards, and a harness that accepts the sentence as an answer has shipped a lie.

You cannot unit-test this against a live model, because the model will not say the same thing twice. You can test it against what the model already said.

That is looptape. A tape is one recorded session: every completion, every tool call, every result as the model was shown it, the final answer. The player replays the tape into your real harness in place of the model. The checks read the result and say, mechanically, whether your code let the lie through.

$ looptape check tapes/01-promise-then-nothing.jsonl
tapes/01-promise-then-nothing.jsonl Promise, then nothing, forever  [as expected: 1 error, 0 warn, 0 info]
  x answer.promise   the final answer promises future work: "Ich sammle dir offene Tasks, Roadmap-Punkte und Agenten-Funde zusammen."

Sixty seconds

npm install -D looptape

# 1. record: point your SDK at the proxy, run your agent as usual (--check prints findings live)
npx looptape record --dir tapes/recorded --check
ANTHROPIC_BASE_URL=http://127.0.0.1:8787 node my-agent.js        # or OPENAI_BASE_URL=http://127.0.0.1:8787/v1

# 2. check: 37 checks over what actually happened
npx looptape check tapes/recorded

# 3. replay: the same conversation through your harness, no model, no network
npx looptape replay tapes/recorded/<session>.jsonl --harness ./harness.js   # examples/guarded-loop.js is the template

No agent yet? npx looptape init writes a one-tape starter and npx looptape check starter.jsonl shows what a finding looks like.

Or skip the recording and start from the seed corpus:

git clone https://github.com/chriszemmel/looptape && cd looptape
npm test                          # 88 tests, no network
node examples/replay-both.js      # the same tape through a naive loop and a guarded one
== naive loop: consumed 1/1 recorded completions, identical to the original run
   answer: "Ich sammle dir offene Tasks, Roadmap-Punkte und Agenten-Funde zusammen." [answered]
  x answer.promise   the final answer promises future work: "Ich sammle dir offene Tasks, ..."

== guarded loop: consumed 1/1 recorded completions, diverged (step 1: harness notes differ (A 0, B 1))
   answer: "I could not complete this: no tool was run this turn, so the answer the model produced had no data behind it and was discarded. ..." [deflected]
   no findings

Same tape. Same model output. One harness shipped the promise; the other bounced it once, got nothing back, and said so. That is the entire method: fixture first, then the fix, then the fixture proves the fix.

What a tape is

JSON Lines. A header, then events in the order they happened. Two sources write them and both normalize to the same run:

{"t":"header","v":1,"id":"a1b2","recorded_at":"2026-09-04T09:00:00Z","source":"wire","provider":"anthropic","provenance":"recorded","model":"claude-fable-5-1"}
{"t":"request","body":{"model":"claude-fable-5-1","system":[…],"tools":[…],"messages":[{"role":"user","content":"Anything on fire?"}]}}
{"t":"response","status":200,"body":{"content":[{"type":"tool_use","id":"toolu_01","name":"list_incidents","input":{"status":"open"}}],"stop_reason":"tool_use"},"stream":true,"ms":1830}
{"t":"request","body":{"…":"…","messages":[…,{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"{\"total\":1,…}"}]}]}}
{"t":"response","status":200,"body":{"content":[{"type":"text","text":"One open incident: the main database is at 91% disk."}],"stop_reason":"end_turn"}}
  • wire tapes hold raw request / response pairs, verbatim, from the proxy or a wrapped client. Nothing is interpreted at write time, so a tape recorded today is still readable when the checks get smarter.
  • loop tapes hold what a harness did: completion, call, result (with the raw size and the text the model was fed), note (a correction the harness injected), answer (with an end reason).

Every header says where the tape came from: recorded is a byte-exact capture, reconstructed was rebuilt from a real incident, synthetic never happened. A tape that lies about this is worth less than no tape.

The format is specified in docs/format.md. It is frozen at v1; a reader refuses a version it does not speak.

The lies

Every check came from an incident. The origin is kept on the check itself, because a check whose origin nobody remembers gets deleted the first time it is inconvenient. The full list with every origin is in docs/checks.md; looptape checks prints it.

| check | what it catches | the incident | |---|---|---| | answer.promise | "I'll pull that together" as the final reply | /items got a promise, then nothing, forever. There is no worker behind that sentence. | | answer.offer | "I can check X" with zero tools run | Asked twice; offered twice; system_health sat in the catalog the whole time. | | answer.deflection | "I can't access X" with nothing tried | An invented inability is indistinguishable from a real one, and it shipped three times in one evening. | | answer.narrated-plan | "First, I need to read greet.js" and the run ends | The most common way a coding agent loses a task. | | answer.narrated-transcript | You called tool(…) / Result: {…} written as prose | A patrol report with a hallucinated status for the wrong domain. Nothing ran. | | answer.raw-json | a tool-shaped blob shown to a human | The loop could not read the call, so the model's intent became the reply. | | claim.artifact | "Saved a memory" with no remember call | The memory stayed unwritten; dedup broke the next run. | | claim.count | "all 327 resources" off a remembered number | The one listing that run returned 40. | | loop.stall | the same call, identical args, three times | A small model re-reading an answer it already had until the budget ran out. | | loop.cut | a tool call cut mid-object, never retried | {"tool":"create_task","args":{"title":"Adapt was filed as "produced no report". | | result.silent-truncation | a result shortened without saying so | A list cut at a byte offset made the bot deny a repo that was past the cut. | | history.prefix-edited | an earlier turn changed between two requests | Claude Fable 5.1 binds thinking blocks to the conversation prefix; edit it and the API rejects every later block. | | history.system-changed | the system prompt rewritten mid-conversation | The same rejection, and the cache restarts from byte zero. | | wire.tool-result-missing | a tool_use never answered | The API rejects it; a harness that bounces a call must still answer the block. | | wire.forced-tool-choice | tool_choice: any on a model that 400s | Carried over from an older integration, invisible in review. | | cache.volatile-system | a timestamp in the system prompt | Every request misses the cache; the bill notices before you do. | | leak.secret | an API key in a message | A tool dumped the environment into the model's context, the provider's logs, and the tape. |

Plus answer.empty, answer.cjk-leak, claim.no-evidence, loop.thrash, loop.unknown-tool, loop.empty-args, result.error-hidden, result.oversized, history.tools-changed, history.assistant-rewritten, history.thinking-dropped, wire.parallel-split, wire.refusal, wire.max-tokens, wire.thinking-config, cache.tool-order, cache.no-breakpoint, cache.never-read.

The predicates behind the answer checks are exported (looksLikePromisedWork, looksLikeDeflection, findUngroundedArtifactClaim, ...) so your loop can bounce a reply in-run with the same definition the tape check uses afterwards. One definition, two layers, and they can never disagree about what a promise is. The guarded example loop is sixty lines on top of the naive one and uses exactly those.

Replay, in a test

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { parseTape, player, recorder, runChecks, failing } from "looptape";

test("the promise tape no longer ships a promise", async () => {
  const tape = parseTape(await readFile("tapes/01-promise-then-nothing.jsonl", "utf8"));
  const p = player(tape, { strict: false });   // the model, replaced by the tape
  const rec = recorder();                       // the replay is itself a tape
  await myLoop({ llm: p.llm, tools: p.tools(catalog), message: "/items", recorder: rec });
  assert.deepEqual(failing(runChecks(rec.run())), []);
});

The player has three faces over one cursor: player.llm(input) for a text-protocol loop, player.messages.create(params) for code written against the Anthropic SDK, player.chat.completions.create(params) for the OpenAI SDK. A wire tape recorded from one dialect is served in the other, converted. Tools answer from the tape too (player.tool(name), player.tools(catalog)), by name and in order, or fall through to your live tools with { live: true }.

When the harness asks for a completion the tape never recorded, the player throws TapeExhausted. That is a divergence, and a test should fail on it rather than improvise a model. strict: false returns an empty completion instead, which is the right mode for a guard the original harness never had: the guard bounces, the tape has no reply to the bounce, and the harness has to end honestly on its own. If it cannot, that is the finding.

Three ways to record

| | change | you get | |---|---|---| | proxy | one environment variable | every wire check, every answer check | | wrap the client | two lines | the same, without a process | | record the loop | wrapLlm, wrapTool, note, answer | everything, including the one number the proxy cannot see: raw size vs. what the model was fed |

const rec = recorder({ title: "nightly digest", model: "claude-fable-5-1" });
const client = rec.wrapAnthropic(new Anthropic());
// ... your loop ...
await rec.save("tapes/recorded/digest.jsonl");   // secrets redacted on the way out

Details, the session header, and the replay entry point: docs/recording.md.

Claude Fable 5.1, specifically

Four of the wire checks exist because the newest models moved the contract under harnesses that worked fine last year:

  • Append-only history. A thinking block's signature records the conversation prefix that produced it. Edit, reorder, or drop an earlier turn, rebuild the system prompt, or change the tool set mid-conversation, and every later block is invalid. history.prefix-edited, history.system-changed, history.tools-changed and history.assistant-rewritten read consecutive requests off the tape and name the first message that changed. A moved cache_control marker is not an edit; a compaction into one summary message is reported as info, not error.
  • Forced tool choice is gone. tool_choice: any / tool is a 400. wire.forced-tool-choice is an error on Fable and Mythos ids, info elsewhere.
  • Thinking configuration. budget_tokens on Opus 4.7+ / Sonnet 5 / Fable, or type: "disabled" on Fable, is a 400. wire.thinking-config.
  • Refusals are HTTP 200. stop_reason: "refusal" with empty or partial content. wire.refusal reports the category so you can see whether your fallback configuration ever engaged.

None of the checks call a model. All of them run on a tape from any provider.

The seed corpus

tapes/ ships 24 tapes: 15 reconstructed from real incidents in a production control plane between July and September 2026, and 9 synthetic controls and wire shapes. Each carries a title, an origin line, a provenance, and expect: the error-level checks it must trip, exactly. looptape check tapes fails the moment a tape drifts in either direction, which is how the corpus stays a regression suite instead of a museum.

$ looptape stats tapes
24 tapes, 46 steps, 23 tool calls
providers: text 16, anthropic 7, openai 1
provenance: reconstructed 15, synthetic 9

Read them. They are short, they are real, and most of them are one sentence long where it matters.

A recorded tape is a production log: the whole conversation, every tool result. Redaction is by shape and cannot know what a customer's name looks like. Read SECURITY.md before you commit one.

In CI

- run: npx looptape check tapes --fail-on warn
- run: npx looptape check tapes/recorded --no-expect --skip cache.*

--json for machines. Directories are walked recursively. Exit 1 on any error or on a broken expect list.

What it is not

  • Not an eval framework. Evals call live models and score outputs; they measure the model. Looptape never calls a model; it measures your harness against a conversation that already happened. Use both.
  • Not a benchmark. A tape is one run, not a distribution.
  • Not a judge. Every check is a deterministic predicate over the tape. No check asks a model whether another model lied.

Why this exists

Code is now cheap. A working agent loop is an afternoon. What is not cheap is the recording of the afternoon it went wrong: the exact sentence, the exact truncated list, the exact edited turn. Those recordings are the only thing that turns "the agent was weird on Tuesday" into a test that fails, and they cannot be generated, only kept.

Looptape was extracted from Foundation, a control plane that runs a fleet of autonomous agents on a mix of frontier and free-tier models, where every check here was first a morning message from a founder asking why the green report was wrong. The loop that grew those guards is 1,800 lines; the guards are the part that generalizes.

Glossary

| word | meaning here | |---|---| | harness (or loop) | your code around the model: call, read, run tools, feed back, repeat. The thing looptape tests. | | tape | one recorded session as a JSONL file: what the model said, what ran, what it was shown, how it ended | | wire level | a tape of raw API requests and responses, from the proxy or a wrapped client | | loop level | a tape a harness wrote about itself: completions, calls, results with sizes, notes, the answer | | player | the tape standing in for the model, so the harness runs for real without one | | recorder | what writes a tape, from a harness, an SDK client, or the proxy | | check | a deterministic predicate over a tape; error, warn or info; each with the incident it came from | | expect | a tape's contract: the error-level checks it must trip, exactly | | provenance | whether a tape is a byte-exact capture, a reconstruction of a real incident, or synthetic |

Layout

bin/looptape.js      the CLI entry point
src/tape.js          the format: parse, validate, serialize, redact
src/normalize.js     one run shape from a wire tape or a loop tape, both API dialects
src/record.js        the recorder: wrap an LLM function, a tool, an Anthropic or OpenAI client
src/proxy.js         the recording HTTP proxy; src/sse.js folds streams back into messages
src/player.js        the model, replaced by the tape
src/guards.js        the predicates (what a promise, an offer, a deflection looks like)
src/checks/          honesty.js (answer.*, claim.*), loop.js (loop.*, result.*), wire.js (history.*, wire.*, cache.*, leak.*)
src/diff.js          the first divergence between two runs
src/report.js        findings for terminals and machines
tapes/               the seed corpus; scripts/seed-tapes.mjs regenerates it
examples/            naive-loop.js, guarded-loop.js, replay-both.js
docs/                format.md, checks.md (generated), recording.md, logo.md
types/index.d.ts     the public surface, typed

Library

Everything the CLI does is a function:

| | | |---|---| | parseTape(text) / serializeTape(tape) / redactTape(tape) | the format | | normalize(tape) | { steps, calls, results, notes, answer, requests, tools, ... } | | recorder(header) | .wrapLlm, .wrapTool, .wrapAnthropic, .wrapOpenAI, .note, .answer, .save | | startProxy({ port, dir, upstream }) | the proxy, in-process | | player(tape, { strict }) | .llm, .messages.create, .chat.completions.create, .tool, .tools | | runChecks(tape, opts) / failing(findings) / CHECKS | the checks | | diffRuns(a, b) | the first divergence | | looksLikePromisedWork(text) and friends | the predicates, for bouncing in-run |

Roadmap

  • A Gemini dialect for the proxy and the player.
  • Tapes as GitHub Actions artifacts: record in CI, attach on failure.
  • A looptape watch mode that checks each tape as the proxy closes it.
  • More seed tapes. Yours, ideally. See CONTRIBUTING.md.

License

MIT © 2026 Chris Zemmel