diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json
index 8b62046e3..c2b2f62c1 100644
--- a/src/core/config/Categories.json
+++ b/src/core/config/Categories.json
@@ -37,6 +37,8 @@
"From Base92",
"To Base85",
"From Base85",
+ "To Base91",
+ "From Base91",
"To Base",
"From Base",
"To BCD",
diff --git a/src/core/operations/FromBase91.mjs b/src/core/operations/FromBase91.mjs
new file mode 100644
index 000000000..e47c2ed6a
--- /dev/null
+++ b/src/core/operations/FromBase91.mjs
@@ -0,0 +1,94 @@
+/**
+ * @author rayane-ara []
+ * @copyright Crown Copyright 2026
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+
+/**
+ * From Base91 operation
+ */
+class FromBase91 extends Operation {
+
+ /**
+ * FromBase91 constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "From Base91";
+ this.module = "Default";
+ this.description = "Decodes Base91 encoded data back into its original binary format.
Example:
fPNKd becomes test";
+ this.infoURL = "https://en.wikipedia.org/wiki/Binary-to-text_encoding";
+ this.inputType = "string";
+ this.outputType = "byteArray";
+ this.args = [];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {byteArray}
+ */
+ run(input, args) {
+ const TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&()*+,./:;<=>?@[]^_`{|}~"';
+
+ if (TABLE.length !== 91) {
+ throw new OperationError("Base91 table is invalid (must contain exactly 91 characters).");
+ }
+
+ const DECODE_TABLE = {};
+ for (let i = 0; i < TABLE.length; i++) {
+ DECODE_TABLE[TABLE[i]] = i;
+ }
+
+ let b = 0; // Bit accumulator (buffer)
+ let n = 0; // Number of bits currently in the buffer
+ let v = -1; // Pending value waiting for its second character (-1 = none)
+ const o = []; // Output byte array
+
+ for (let i = 0; i < input.length; i++) {
+ const c = input[i];
+
+ // Skip characters that are not part of the Base91 alphabet
+ if (!(c in DECODE_TABLE)) continue;
+
+ const p = DECODE_TABLE[c];
+
+ if (v < 0) {
+ // First character of a pair: store the value and wait for the second
+ v = p;
+ } else {
+ // Second character of a pair: reconstruct the encoded value
+ v += p * 91;
+
+ // Push the lower 13 bits into the bit buffer
+ b |= v << n;
+ n += (v & 8191) > 88 ? 13 : 14;
+
+ // Reset v for the next pair
+ v = -1;
+
+ // Extract all complete bytes from the buffer
+ do {
+ o.push(b & 255); // Extract the lowest 8 bits as a byte
+ b >>= 8;
+ n -= 8;
+ } while (n > 7);
+ }
+ }
+
+ // Handle the final leftover character (if the input had an odd length)
+ if (v > -1) {
+ o.push((b | v << n) & 255);
+ }
+
+ return o;
+ }
+
+}
+
+export default FromBase91;
+
diff --git a/src/core/operations/ToBase91.mjs b/src/core/operations/ToBase91.mjs
new file mode 100644
index 000000000..0d1c945f0
--- /dev/null
+++ b/src/core/operations/ToBase91.mjs
@@ -0,0 +1,94 @@
+/**
+ * @author rayane-ara []
+ * @copyright Crown Copyright 2026
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+
+/**
+ * To Base91 operation
+ */
+class ToBase91 extends Operation {
+
+ /**
+ * ToBase91 constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "To Base91";
+ this.module = "Default";
+ this.description = "Encodes binary data into Base91 format. Base91 is an advanced method for encoding binary data as ASCII characters, resulting in a more compact string than Base64.
Example:
test becomes fPNKd";
+ this.infoURL = "https://en.wikipedia.org/wiki/Binary-to-text_encoding";
+ this.inputType = "byteArray";
+ this.outputType = "string";
+ this.args = [];
+ }
+
+ /**
+ * @param {byteArray} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ // Base91 alphabet: 91 printable ASCII characters
+ const TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&()*+,./:;<=>?@[]^_`{|}~"';
+
+ if (TABLE.length !== 91) {
+ throw new OperationError("Base91 table is invalid (must contain exactly 91 characters).");
+ }
+
+ let b = 0; // Bit accumulator (buffer)
+ let n = 0; // Number of bits currently in the buffer
+ let o = ""; // Output string
+
+ for (let i = 0; i < input.length; i++) {
+ // Append the current byte to the bit buffer (LSB first)
+ b |= input[i] << n;
+ n += 8;
+
+ // We need at least 13 bits to attempt encoding
+ if (n > 13) {
+ // Extract the lower 13 bits
+ let v = b & 8191; // 8191 = 0x1FFF = 2^13 - 1
+
+ if (v > 88) {
+ // 13 bits are sufficient to encode this value:
+ // consume 13 bits from the buffer
+ b >>= 13;
+ n -= 13;
+ } else {
+ // Value is too small (=< 88): a 14-bit encoding avoids
+ // wasting range, so we take one extra bit instead
+ v = b & 16383; // 16383 = 0x3FFF = 2^14 - 1
+ b >>= 14;
+ n -= 14;
+ }
+
+ // Each value is encoded as exactly 2 characters
+ o += TABLE[v % 91] + TABLE[Math.floor(v / 91)];
+ }
+ }
+
+ // Handle remaining bits in the buffer after the main loop
+ if (n) {
+ // Always emit at least one character for the leftover bits
+ o += TABLE[b % 91];
+
+ // Emit a second character only if needed:
+ // more than 7 leftover bits (i.e. a full byte was split), OR
+ // the remaining value exceeds the single-character range (> 90)
+ if (n > 7 || b > 90) {
+ o += TABLE[Math.floor(b / 91)];
+ }
+ }
+
+ return o;
+ }
+
+}
+
+export default ToBase91;
+
diff --git a/tests/node/tests/nodeApi.mjs b/tests/node/tests/nodeApi.mjs
index 92d4d9911..9db4cef34 100644
--- a/tests/node/tests/nodeApi.mjs
+++ b/tests/node/tests/nodeApi.mjs
@@ -136,7 +136,7 @@ TestRegister.addApiTests([
it("chef.help: returns multiple results", () => {
const result = chef.help("base 64");
- assert.strictEqual(result.length, 13);
+ assert.strictEqual(result.length, 14);
}),
it("chef.help: looks in description for matches too", () => {