New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

twiceshy

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

twiceshy

Zero-setup repeat-action guard for AI agents

latest
Source
npmnpm
Version
0.1.2
Version published
Weekly downloads
0
Maintainers
1
Weekly downloads
 
Created
Source

twiceshy

Twiceshy is a library that stops an AI agent from doing the same money-moving action twice.

The Kola scenario, in four lines:

  • Kola runs a food delivery app in Lagos. His refund agent sends a 1,500 naira refund to Ife through Paystack.
  • Paystack is slow, so the agent's request times out after 30 seconds, even though the refund went through.
  • The agent retries, and Ife receives 3,000 naira instead of 1,500.
  • Twiceshy sits between the agent and the tool. It records the first refund, blocks the retry, and says the refund is in doubt until someone checks.

30 second install and use

npm install twiceshy better-sqlite3

better-sqlite3 is optional: the examples below use MemoryStore first, and the SQLite store later.

import { createGuard, MemoryStore } from 'twiceshy';

let refundsSent = 0;

const guard = createGuard({ store: new MemoryStore(), window: '10m' });
const refund = guard.wrap(
  'paystack.refund',
  async (args) => {
    refundsSent += 1;
    return { receipt: 'PS-T123', amount: args.amount };
  },
  { keyFields: ['transaction', 'amount'] },
);

await refund({ transaction: 'T123', amount: 1500, reason: 'missing drink' });
await refund({ transaction: 'T123', amount: 1500, reason: 'customer asked again' });

console.log(`Refunds sent: ${refundsSent}.`);
console.log('The second call returned the first result without running the refund again.');
await guard.close();

Real output:

Refunds sent: 1.
The second call returned the first result without running the refund again.

The second call had a different reason, but reason is free text and not part of the key, so Twiceshy saw the same refund. See Choosing keyFields.

For several processes on one machine, use the SQLite store (the optional better-sqlite3 driver is installed by the command above; run it again any time):

npm install twiceshy better-sqlite3
import { createGuard } from 'twiceshy';
import { SqliteStore } from 'twiceshy/sqlite';

const guard = createGuard({ store: new SqliteStore({ path: '~/.twiceshy/actions.db' }) });
const outcome = await guard.run(
  { tool: 'paystack.refund', args: { transaction: 'T1', amount: 1500 } },
  async () => ({ receipt: 'PS-T1' }),
);
console.log(`Outcome: ${outcome.status}.`);
await guard.close();

Real output:

Outcome: executed.
Value: {"receipt":"PS-T1"}.

SqliteStore needs Node 22 or newer. better-sqlite3 12.x ships prebuilt binaries for every Node 22 and Node 24 release. See Limits.

Concepts

  • Action: one call to one tool with one set of arguments, for example a refund of 1,500 naira for transaction T123.
  • Fingerprint: a short ID built from the tool, the server, the scope, and the key arguments. Two calls with the same meaning get the same fingerprint.
  • Key fields: the argument fields that define the action, for example transaction and amount. Everything else is ignored.
  • Identity: the fingerprint, or the explicit key when you pass one. A record is looked up by identity.
  • Scope: who the action belongs to, for example one customer or one agent. It is part of the fingerprint.
  • Record: what Twiceshy stores about one action: state, timing, redacted arguments, and the redacted result. Field-name redaction cannot find a secret inside free text.
  • Window: how long after an action finishes an identical action counts as a duplicate. Default: 10 minutes.
  • Lease: a timed claim that says "this action is running now". If the process dies, the lease runs out.
  • In doubt: Twiceshy cannot tell whether the action happened. It stops and asks a person.
  • Duplicate: the action already ran inside the window, so Twiceshy returns the original result instead of running it again.
  • Blocked: Twiceshy refuses to run the action because the record is in doubt, in progress, or because an explicit key was reused with different arguments.

Record states

StateMeaning
IN_FLIGHTClaimed and running. It has a lease.
SUCCEEDEDFinished, result stored.
FAILED_DEFINITEFailed in a way that certainly had no effect. Safe to retry.
IN_DOUBTMay or may not have happened. A person must decide.
RESOLVED_SUCCEEDEDA person confirmed it happened. Acts like SUCCEEDED.
RESOLVED_FAILEDA person confirmed it did not happen. Acts like FAILED_DEFINITE.

Configuration reference

createGuard(options) validates every option with Zod. A bad option throws one CONFIG_INVALID error that lists every problem.

OptionDefaultWhat it does
storerequiredWhere records live. Use new MemoryStore() or new SqliteStore({ path }).
namespace'local'Becomes the server name inside fingerprints.
window'10m'How long a finished action counts as a duplicate. Accepts '30s', '10m', '2h', or milliseconds.
retention'7d'How long records are kept for audit.
onStoreFailure'closed''closed' refuses guarded calls when the store is down. 'open' runs them and emits guard-bypassed.
waitForInFlightMs5000How long a second caller waits for a running action.
maxResultBytes262144Largest result stored in full. A bigger result fails with RESULT_TOO_LARGE; set storeResult: false to store nothing and accept a summary on duplicates.
timeoutMs30000Default time limit for one call.
leaseMstimeoutMs + 30000Claim duration. Tests often shorten it.
closeTimeoutMs30000How long close() waits for running calls.
closeStoretrueSet false when several guards share one store.
trustIdempotentHinttrueTrusts the MCP idempotentHint annotation.
redact[]Extra field names to redact, on top of the built-in list.
classifyErrorundefinedReplaces the built-in error classifier.
hooks{}Functions that receive structured events. See below.
clocksystem clockInjectable time and timers for tests.

guard.wrap(tool, fn, options) options:

OptionWhat it does
keyFieldsThe argument fields that define the action. Dot paths work.
ignoreFieldsUsed when keyFields is not set: all arguments except these.
normalizePer field: number, trim, lowercase, lowercase-trim, or a function.
scopeA function that computes the scope from the arguments.
timeoutMsTime limit for this tool.
storeResultfalse stores no result at all; a duplicate then returns a short text summary saying the action already ran.

guard.run(request, fn) returns { status: 'executed', value, record } or { status: 'duplicate', value, record, originalAt }. It throws a TwiceshyError subclass when the call is blocked.

Guard methods:

MethodWhat it does
run(request, fn)Guards one call and returns the full outcome.
wrap(tool, fn, options)Returns a function with the same parameters and return type as fn.
list(filter)Lists records, newest first.
get(id)Reads one record, or returns null.
resolve(id, { as, by, note })A person settles an in-doubt record. by is required.
allowRepeat(fingerprint, { by, note })A person approves exactly one extra execution.
purge(olderThan)Removes records past retention.
close()Stops timers, waits for running calls, and closes the store.

Hooks receive one structured event per decision: onExecute, onDuplicate, onBlocked, onInDoubt, onLateCompletion, onResolutionConflict, onStoreWriteFailed, onGuardBypassed, onWarning. Each event carries type, at, recordId, fingerprint, tool, server, scope, durationMs, and attempt. An exception inside a hook is written to stderr and never breaks the guarded call.

Results, cancellation, and human decisions

  • Results are redacted before storage. The same field-name rules used for arguments apply to a successful result. A duplicate returns the stored, redacted copy. The first caller still gets the real result.
  • storeResult: false stores nothing. The action's record keeps no result, and a duplicate returns a short summary instead.
  • Cancellation before start is a definite failure. If the caller cancels after the claim but before the tool function is invoked, the record becomes FAILED_DEFINITE with reason "cancelled before start", so the next call may run. Cancellation after invocation leaves the record IN_DOUBT.
  • Human decisions win. A late result never overwrites a human resolution. If it agrees, nothing changes. If it contradicts the human, Twiceshy emits a resolution-conflict event. When a person said "failed" but the action actually succeeded, the record returns to IN_DOUBT and blocks the next identical call until a person decides again.

Inspecting and resolving records

The library has no command-line tool. The twiceshy command is installed by @twiceshy/mcp-proxy:

npm install @twiceshy/mcp-proxy
npx twiceshy list --config twiceshy.config.json

With nothing installed, run the same commands as npx -y @twiceshy/mcp-proxy <command>, for example npx -y @twiceshy/mcp-proxy show <record-id>. The config file needs the same store as your library code, for example:

{ "store": { "type": "sqlite", "path": "./.twiceshy/actions.db" }, "mcpServers": {} }

resolve <id> --succeeded|--failed --by <name> settles an in-doubt record, and allow <fingerprint> --by <name> approves one repeat. Both require the name of the person deciding.

Limits

  • MemoryStore loses all records when the process restarts. Use SqliteStore for anything that matters.
  • SqliteStore protects one machine only. Agents on several servers need the future Redis or Postgres store.
  • A wrong keyFields choice is the main way protection fails. Read Choosing keyFields.
  • Twiceshy blocks legitimate repeats inside the window. A person can approve one with allowRepeat.
  • Twiceshy needs Node 22 or newer.

The full list is in Limits.

Guides

License

MIT. Copyright 2026 Joseph Brendan, Dev and Design HQ.

Keywords

mcp

FAQs

Package last updated on 15 Oct 2026

Related posts