·
10 min read
Write to Fresh Jots from JavaScript — `npm install freshjots`, plain `fetch`, or batched
Write to Fresh Jots from JavaScript — `npm install freshjots`, plain `fetch`, or batched
A spin-off from Write a note from any project, focused on JavaScript. Three flavors:
- **`npm install freshjots`** — the official zero-dependency Node client. The recommended path.
- **Plain `fetch`** — Node 18+ has it globally; the same code runs in the browser. For when you don't want a dependency.
- **Bulk + batching** — create up to 50 notes in one atomic POST via `/api/v1/notes/bulk`.
All three hit the same `/api/v1/notes/by-filename/<name>/append` endpoint (or `/notes/bulk` for batching). You'll need a Fresh Jots API token. If you don't have one, see Get & set your Fresh Jots API token.
`npm install freshjots` — the recommended path
`npm install freshjots` — the recommended path
npm install freshjots
Zero runtime dependencies — it uses Node 18's global `fetch` for HTTP. Adds no transitive bloat. Works with npm, pnpm, yarn, or bun.
Three lines of use
Three lines of use
import { Client } from "freshjots";
await new Client().append("deploy-log", "deploy ok — sha=abc123");
`new Client()` reads `FRESHJOTS_TOKEN` from `process.env` by default. If the variable isn't set, the constructor throws immediately so you find out at startup, not three hours into a long-running script.
The whole API in four methods
The whole API in four methods
import { Client } from "freshjots";
const client = new Client();
// 1. Append text to a note — creates it the first time.
await client.append("cron-jobs-prod", "backup ok");
// 2. Read a note's full plain-text body.
const note = await client.note("cron-jobs-prod");
console.log(note.plain_body);
// 3. List all your notes (summary projection).
const notes = await client.notes();
for (const n of notes) console.log(`${n.filename}\t${n.title}`);
// 4. Create a new note explicitly (errors if the filename is taken).
await client.create({
filename: "research-2026-q2",
body: "Initial outline.",
title: "Q2 Research",
});
That's the full surface: `notes()`, `note(filename)`, `create({...})`, `append(filename, text)`. No paginators, no plugin system, no event-emitter abstractions. The package's job is to *not get in your way*.
Error handling — `ApiError`
Error handling — `ApiError`
Any non-2xx response throws `ApiError`:
import { Client, ApiError } from "freshjots";
const client = new Client();
try {
await client.append("huge-note", "x".repeat(5_000_000));
} catch (e) {
if (e instanceof ApiError) {
console.log(`${e.status} ${e.code}: ${e.message}`);
// 413 content_too_large: body exceeds the per-note 3 MB cap
} else {
throw e;
}
}
The error carries four useful fields:
- `e.status` — the HTTP status code (`401`, `403`, `404`, `413`, `422`, `429`, ...)
- `e.code` — a stable string code that's safe to branch on
- `e.message` — the human-readable message
- `e.details` — optional structured payload for validation errors
Stable error codes: `unauthenticated`, `forbidden`, `not_found`, `validation_failed`, `cap_exceeded`, `storage_cap_exceeded`, `content_too_large`, `content_type_mismatch`, `rate_limited`. Branch on `e.code`, not `e.message` — the code is part of the API contract; the message can be tweaked for clarity without breaking your code.
try {
await client.append("daily-log", entry);
} catch (e) {
if (e.code === "rate_limited") {
await new Promise((r) => setTimeout(r, 60_000));
await client.append("daily-log", entry);
} else if (e.code === "cap_exceeded") {
opsAlert("Fresh Jots cap exceeded; dropping log entry.");
} else {
throw e;
}
}
Passing the token explicitly
Passing the token explicitly
If `FRESHJOTS_TOKEN` isn't right (multiple accounts, a worker getting its token via a different secrets manager, a test that wants a known-bad token):
const client = new Client({ token: "mn_yourrealtokenhere" });
Tokens are always `mn_` prefix followed by ~40 characters of random alphanumerics. Mint a real one at [/settings/api_tokens](https://freshjots.com/settings/api_tokens).
Pointing at a different base URL (testing, staging)
Pointing at a different base URL (testing, staging)
const client = new Client({
token: "mn_test…",
baseUrl: "https://staging.freshjots.com/api/v1",
});
Plain `fetch` — when you don't want the package
Plain `fetch` — when you don't want the package
For a single-file script or a project that's hostile to new dependencies, Node 18+'s global `fetch` gives you the same thing in eight lines:
const res = await fetch(
"https://freshjots.com/api/v1/notes/by-filename/deploy-log/append",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FRESHJOTS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ text: "deploy ok — sha=abc123" }),
},
);
if (!res.ok) throw new Error(`fjots append ${res.status}: ${await res.text()}`);
Fine for a one-off. For anything that runs unattended, add retry on transient 5xx/429:
async function appendWithRetry(filename, text, attempts = 3) {
for (let i = 0; i < attempts; i++) {
const res = await fetch(
`https://freshjots.com/api/v1/notes/by-filename/${encodeURIComponent(filename)}/append`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FRESHJOTS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
},
);
if (res.ok) return res.json();
if (![429, 500, 502, 503, 504].includes(res.status)) break;
await new Promise((r) => setTimeout(r, 500 * (i + 1)));
}
throw new Error("fjots append failed");
}
Compare that to:
await new Client().append("deploy-log", "deploy ok");
If you're going to write the second block more than once, install the package.
Browser `fetch` — yes, it works
Browser `fetch` — yes, it works
The Fresh Jots API allows cross-origin requests from any origin (`origins "*"` on `/api/v1/*`, no credentials, bearer-token auth only). So a `fetch()` from a page hosted anywhere will work — no proxy required, no CORS configuration on your side.
// In a browser page, a React component, etc.
async function appendFromBrowser(filename, text) {
const res = await fetch(
`https://freshjots.com/api/v1/notes/by-filename/${encodeURIComponent(filename)}/append`,
{
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
},
);
return res.json();
}
**One critical caveat: never put a personal API token in browser JavaScript that real users will run.** The token gives whoever has it full read-write to your notes. For browser-side use, the safe patterns are:
- **Server-side proxy.** Your backend has the token; the browser hits your backend, which hits Fresh Jots. The token never reaches the user.
- **Per-user tokens** (Team tier). Each end-user gets their own token, scoped to their workspace. The browser holds their token, not yours.
- **Internal-only tools.** A staff-only admin tool where every browser session belongs to someone you trust with the token.
The "client side `fetch`" pattern is great for build scripts running in Node, browser extensions that authenticate as the developer who installed them, Electron apps, Cloudflare Workers — *not* for unauthenticated public pages.
Batching — `POST /api/v1/notes/bulk`
Batching — `POST /api/v1/notes/bulk`
When you have many notes to create at once (importing a corpus, seeding a workspace, migrating from another tool), the bulk endpoint creates up to **50 notes in one atomic transaction**:
const res = await fetch("https://freshjots.com/api/v1/notes/bulk", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FRESHJOTS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
notes: [
{ filename: "log-1", plain_body: "first", format: "plain" },
{ filename: "log-2", plain_body: "second", format: "plain" },
// … up to 50 entries …
],
}),
});
const data = await res.json();
// 201 Created → { created: [<full note>, <full note>, ...] }
// 422 Unprocessable Entity → { failed: [{ index, code, message }, ...] }
"Atomic" here means **all 50 land or none do**. If any row fails validation, the response is a 422 with a `failed:` array indexing back to your input — `failed[0].index === 3` means your fourth note (zero-based) was the problem. Fix all the issues in one local pass, resend.
The endpoint creates notes, not appends. To batch *appends* to a single note, call `client.append()` in parallel:
await Promise.all(
events.map((e) => client.append("event-log", JSON.stringify(e))),
);
Concurrent appends to one note can interleave at the byte level (the API serializes the writes, but your *order* is whatever order they arrive in). For ordered writes to one note, await sequentially.
Loading the token
Loading the token
Three good options, depending on how you ship:
- **Shell profile** (`~/.bashrc`, `~/.zshrc`, `~/.config/fish/config.fish`) — see Get & set your Fresh Jots API token. Best for local-machine work.
- **`.env` + `dotenv` / `node --env-file=.env`** — Node 20.6+ has `--env-file` built in, no library needed. Otherwise `npm install dotenv` and `import "dotenv/config"`. Put `FRESHJOTS_TOKEN=mn_…` in a git-ignored `.env`. Best for project-scoped tokens and CI.
- **Container / orchestrator secret** — set it via Kubernetes, ECS, Fly.io, Docker Compose `environment:`, etc. Best for production.
The package reads `process.env.FRESHJOTS_TOKEN` once when `new Client()` is instantiated. Each option above gets the variable into the environment; the package doesn't care which.
Patterns that work
Patterns that work
import { Client } from "freshjots";
const fjots = new Client();
export function fjotsLogger(req, res, next) {
res.on("finish", () => {
fjots
.append("express-requests", `${req.method} ${req.path} → ${res.statusCode}`)
.catch(() => {}); // never fail a real request because the log failed
});
next();
}
Use `res.on("finish")` so the append happens *after* the response is sent — the user doesn't wait on the network round-trip.
Next.js API route — fire-and-forget telemetry
Next.js API route — fire-and-forget telemetry
// app/api/deploy/route.js
import { Client } from "freshjots";
const fjots = new Client();
export async function POST(request) {
const payload = await request.json();
// ... do the deploy ...
fjots.append("deploy-log", `deploy ok — ${payload.sha}`).catch(() => {});
return Response.json({ ok: true });
}
Not awaiting the `append` means the response returns immediately. Errors are swallowed by `.catch(() => {})` — for a deploy notifier, you'd rather miss one log line than 500 the deploy request.
Cloudflare Workers — append from an edge worker
Cloudflare Workers — append from an edge worker
// worker.js
import { Client } from "freshjots";
export default {
async fetch(request, env) {
const fjots = new Client({ token: env.FRESHJOTS_TOKEN });
await fjots.append(
"edge-events",
`${request.method} ${new URL(request.url).pathname} from ${request.headers.get("cf-ipcountry") || "??"}`,
);
return new Response("ok");
},
};
Workers ship a fetch-compatible runtime, so the package works as-is. Set `FRESHJOTS_TOKEN` as a Worker secret with `wrangler secret put FRESHJOTS_TOKEN`.
Bun — same code, different runtime
Bun — same code, different runtime
import { Client } from "freshjots";
const fjots = new Client(); // reads Bun.env.FRESHJOTS_TOKEN (also exposed as process.env)
await fjots.append("bun-runs", `started at ${new Date().toISOString()}`);
The package has no Bun-specific code; it just uses global `fetch` and `process.env`, both of which Bun supports.
Vitest / Node test — log a test summary
Vitest / Node test — log a test summary
// vitest.setup.js (or test/setup.js for node --test)
import { afterAll } from "vitest";
import { Client } from "freshjots";
const fjots = new Client();
afterAll(async () => {
const status = global.__VITEST_FAILED__ ? "FAIL" : "OK";
const sha = process.env.GITHUB_SHA?.slice(0, 7) || "local";
await fjots
.append("test-runs", `[${sha}] ${status} — ${new Date().toISOString()}`)
.catch(() => {});
});
Searchable history of which commits passed and which failed — without paying for a CI dashboard.
Going further
Going further
- **Source for the package:** github.com/Goran-Arsov/freshjots-js. MIT licensed, ~80 lines including comments.
- **TypeScript types coming.** The package is ESM JS today; types live in a follow-up post in this series. In the meantime the four methods are simple enough to wrap in your own `.d.ts` if you need strict typing.
- **Other languages — same pattern, different HTTP client.** See the hub: Write a note from any project.
- **The CLI** — if you find yourself shelling out from Node to write notes, the Notes From Your Terminal does the same thing in one line of shell.
- **Dead-man alerts** — pair a per-script note with `append_deadline_hours` and Fresh Jots emails you when the script goes silent. See Everything you can do here.
Three lines today, four package methods tomorrow, an entire ops telemetry pipeline by next month. All over plain HTTP — no SaaS lock-in, no sidecar, no `node_modules` churn (the package adds zero transitive deps).