Eighteen ciphers. One call.
Caesar to Enigma behind one small API. Encode, decode, brute force a Caesar, count letters. All in your process - no network, no keys, nothing to configure. Library, CLI, MCP server, Pi and OMP extensions. For lessons, and for the puzzle you're stuck on at 1am.
- 18
- ciphers
- 8
- families
- 5
- agent tools
- 0
- network calls
Encode and decode
Same two methods, every cipher
create("vigenere") gives you a class with encode and decode. Each cipher keeps its own option names. Case survives by default, punctuation passes through, and stripNonAlpha flattens the input when a puzzle wants one clean block. This panel walks through 18 ciphers. Nothing here is a recording, the library computes every value in your browser.
- create(name) returns one cached instance. resolveCipher fixes case and spaces, nothing fuzzier
- Every result is { text, cipher, operation, options }. One log line tells you what ran
- Missing key is a MissingOptionError, shift 26 an InvalidOptionError. Never a silent identity
import { create } from "@agntn/ciphers";
// Caesar Cipher, substitution-shift
const cipher = create("caesar");
const encoded = cipher.encode("ATTACK AT DAWN", { shift: 3 });
encoded.text; // "DWWDFN DW GDZQ"
const back = cipher.decode(encoded.text, { shift: 3 });
back.text; // "ATTACK AT DAWN", decode takes the same optionsBrute force
Twenty-five shifts, one line each
A Caesar has 25 keys and the fastest attack is all of them. The list is the current plaintext shifted by 3, then decoded with every shift, the hit in the accent. Same loop behind the CLI command and the agent tool, there was no reason to write it twice.
- ciphers brute prints every shift. You pick the one that reads as English
- cipher_brute_caesar returns the same list as text, so a model can pick too
- Shift 0 is not on the list. An identity is not a decode
$ciphers brute "DWWDFN DW GDZQ"
25 shifts
- shift= 1→CVVCEM CV FCYP
- shift= 2→BUUBDL BU EBXO
- shift= 3→ATTACK AT DAWN
- shift= 4→ZSSZBJ ZS CZVM
- shift= 5→YRRYAI YR BYUL
- shift= 6→XQQXZH XQ AXTK
- shift= 7→WPPWYG WP ZWSJ
- shift= 8→VOOVXF VO YVRI
- shift= 9→UNNUWE UN XUQH
- shift=10→TMMTVD TM WTPG
- shift=11→SLLSUC SL VSOF
- shift=12→RKKRTB RK URNE
- shift=13→QJJQSA QJ TQMD
- shift=14→PIIPRZ PI SPLC
- shift=15→OHHOQY OH ROKB
- shift=16→NGGNPX NG QNJA
- shift=17→MFFMOW MF PMIZ
- shift=18→LEELNV LE OLHY
- shift=19→KDDKMU KD NKGX
- shift=20→JCCJLT JC MJFW
- shift=21→IBBIKS IB LIEV
- shift=22→HAAHJR HA KHDU
- shift=23→GZZGIQ GZ JGCT
- shift=24→FYYFHP FY IFBS
- shift=25→EXXEGO EX HEAR
Frequency
Count letters before guessing a key
analyzeFrequency(text, "en") tells you whether a ciphertext still has English underneath. A shift keeps the histogram shape and only moves it. A Vigenère flattens it. The panel counts whatever the walk just produced, so watch it collapse when Enigma comes around.
- Counts sorted by frequency, expected order for English or Polish next to them
- Index of coincidence near 0.067 is a substitution, near 0.038 polyalphabetic or random
- No letters in, undefined out. Not an empty histogram
analyzeFrequencycaesar output
12 letters · IC 0.136
- D4
- W3
- F1
- N1
- G1
- Z1
- Q1
- expected
- ETAOINSHRDLC
- actual
- DWFNGZQ
Ciphers
Eighteen ciphers, eight families
Latin alphabets are A to Z. Playfair and Polybius fold J into I, tap code shares C and K, Bacon uses 26 letters not 24, Enigma is the Wehrmacht M3 with rotors I, II, III and reflector B. Every one of those is a choice, and every choice is written down on the cipher's page. A puzzle answer you can't reproduce is not an answer.
- Shift, reflection and multiplicative substitutions, three polyalphabetics, one digraph
- Six fractionations from Polybius to ADFGVX, two transpositions, one Enigma M3
- Each page lists the options, the keyspace and the conventions, like I and J sharing a cell
Agents
Five tools, three hosts
ciphers mcp serves the tools over stdio, the Pi and OMP extensions render them in the terminal. All three call the same executors, so they answer identically and a fix lands once. Nothing leaves the machine, there is nowhere for it to go.
- cipher_encode, cipher_decode, cipher_brute_caesar, cipher_frequency, cipher_info
- Arguments checked against the published JSON Schema before a cipher sees them
- Text and key lengths are bounded. A model cannot hand the process a novel
toolcipher_encode
MCP · Pi · OMP
input
{
"cipher": "caesar",
"text": "ATTACK AT DAWN",
"shift": 3
}output
{
"content": [{ "type": "text", "text": "DWWDFN DW GDZQ" }],
"details": {
"cipher": "caesar",
"operation": "encode",
"options": { "shift": 3 }
}
}Your cipher
Extend Cipher, call register
Every built-in is a concrete class extending the exported abstract Cipher. Yours is the same shape, one file. Throw InvalidOptionError when an option is wrong and let normalizeError wrap the rest. No base class magic, no plugin manifest.
- name(), info(), encode() and decode(). The same four the built-ins implement
- register(name, Class) makes it visible to create and resolveCipher
- Register a name again and the cached instance is dropped, so a hot reload takes
import { Cipher, register } from "@agntn/ciphers";
import type { CipherInfo, CipherResult } from "@agntn/ciphers";
class Reverse extends Cipher {
name() {
return "reverse";
}
info(): CipherInfo {
return {
name: "reverse",
label: "Reverse",
description: "The text backwards",
family: "transposition",
selfInverse: true,
options: [],
};
}
encode(text: string): CipherResult {
return {
text: [...text].reverse().join(""),
cipher: "reverse",
operation: "encode",
options: {},
};
}
decode(text: string): CipherResult {
return { ...this.encode(text), operation: "decode" };
}
}
register("reverse", Reverse);Start with one command
Pre-1.0, so pin exact versions. And these are classical ciphers - fine for a riddle, a lesson or a CTF, useless for a secret. Use a real primitive for those, seriously.