Keep a Claude Code hook to appending a line and POSTing a payload, and let a server write every note. Shell hooks that built my daily journal note directly hit race conditions and schema violations. Four more patterns from the same two weeks follow: a path guard, MCP stdout, SuiteQL paging and fan-out deduplication.
Keep hooks to appending and POSTing, and write notes on the server
The server is the REST server from vaultctl, my Obsidian vault tool. The vaultctl package on npm is the CLI, and its latest version is 0.5.0. The REST and MCP servers below are not in it.
The PostToolUse hook ran on every tool call. It appended one line to a JSONL buffer in /tmp and made no HTTP call. Read and task-tracking tools skipped Python entirely:
# PostToolUse hook: append enriched tool call to JSONL buffer
# ...
# Fast path ~8ms (grep/cut), full path ~41ms (python3)
# ...
TOOL=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4)
SID=$(echo "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | cut -d'"' -f4)
BUFFER="/tmp/claude-activity-${SID:-unknown}.jsonl"
Other tools get a richer line, tagged with a milestone when the command is a commit, a test run or a deploy:
{"tool": "Bash", "ts": "2026-03-14T01:34:59Z", "target": "git commit -m \"fix tooltip rendering\"", "milestone": "git_commit", "commit_msg": "fix tooltip rendering"}
The Stop hook read the buffer in one python3 call. It counted tools, labelled the session, and POSTed the raw events to /activity/batch and a summary to /activity/digest. Then it deleted the buffer. The digest POST:
# Auth from Keychain
AUTH_PASS=$(security find-generic-password -a "admin" -s "vaultctl-server" -w 2>/dev/null || echo "")
AUTH_FLAG=""
if [ -n "$AUTH_PASS" ]; then
AUTH_FLAG="-u admin:${AUTH_PASS}"
fi
# ...
# 2. POST digest to create/append daily note (server-side)
DIGEST_RESULT=""
if [ -f "$DIGEST_FILE" ]; then
DIGEST_RESULT=$(curl -s -X POST \
$AUTH_FLAG \
-H "Content-Type: application/json" \
-d @"$DIGEST_FILE" \
--connect-timeout 1 \
--max-time 2 \
"http://127.0.0.1:3333/api/v1/activity/digest" 2>/dev/null || echo "")
fi
Note: Claude Code fires Stop each time Claude finishes a response, not once per session (the tracking post has the detail). Each digest covers only the tool calls since the previous response, so one long session can leave many short entries.
The server end validates, fills defaults and hands off:
router.post('/digest', async (req: Request, res: Response) => {
// ...
const digest = req.body;
if (!digest.sessionId || !digest.startedAt || !digest.endedAt) {
res.status(400).json({ ok: false, error: 'sessionId, startedAt, and endedAt are required' });
return;
}
// Defaults
digest.project = digest.project ?? null;
// ...
const result = await engine.createSessionDigest(digest);
res.json({ ok: true, data: { path: result.path, operation: result.operation, eventId: result.event.id } });
createSessionDigest stores the digest as an event and builds a markdown entry. A new daily note goes through the vault's own writeNote() with full frontmatter. For an existing note, the server adds a ## Sessions heading if there is none, then appends the entry to the end of the file. One entry from the 14 March note:
### 19:05-19:07 | vaultctl | coding (2.5 min)
- 25 tool calls: Read 10, Edit 8, Bash 2, Grep 2, Skill 1
- Files: STATE.md, patterns.md, decisions.md, vaultctl.md, MEMORY.md
- Skills: update-codex
- Agents: Update vaultctl vault note
Read the password from where the server reads it
The first flush hook had no credentials (the tracking post). Since v2.2.0, the server's launcher reads its password from the macOS Keychain:
if [ -z "$AUTH_PASSWORD" ]; then
AUTH_PASSWORD=$(security find-generic-password -a "admin" -s "vaultctl-server" -w 2>/dev/null || echo "")
export AUTH_PASSWORD
fi
The Stop hook read the same Keychain item and parsed the reply. Its session message said daily note created or daily note updated only when the reply named an operation. Anything else printed digest sent, including a timeout and a 401. Treat digest sent as unconfirmed.
--connect-timeout 1 stops a dead server from blocking the Stop hook. The total cap is 2 seconds, down from 5. A healthy call takes under 100 ms.
Date the daily note in local time
The first digest built the file name from a UTC date and the heading's weekday from local time, so on 13 March, in Pacific daylight time (UTC-7), anything after 17:00 landed in the next day's file. The note for 14 March, a Saturday, was created on 13 March and is headed # 2026-03-14, Friday.
Use one clock for both. The fix landed on 17 March:
// WRONG: UTC date
const today = now.toISOString().slice(0, 10);
// CORRECT: the same local-date helper the event store uses
const today = localToday();
Route every path through one guard
vaultctl had a safeJoin() helper from v0.3.0 in February, and the CLI commands used it. At the v2.0.0 tag, writeNote(), two server routers and two MCP tool handlers still joined or resolved paths with no check. Nothing stopped a ../../../ in a note path from reaching files outside the vault. It was fixed before v2.1.0.
export function safeJoin(vaultPath: string, relativePath: string): string {
const absVault = resolve(vaultPath);
const absTarget = resolve(absVault, relativePath);
if (!absTarget.startsWith(absVault + sep) && absTarget !== absVault) {
throw new Error(`Path traversal blocked: ${relativePath} escapes vault root`);
}
return absTarget;
}
Call it at every entry point: CLI, HTTP and MCP.
Note: The inline checks added in that fix test
absPath.startsWith(vaultPath)with no separator. That also passes/notes-old/xwhen the vault is/notes.safeJoincompares against the root plussep.
Keep an MCP server's stdout for the protocol
An MCP server on stdio uses stdout as its transport. vaultctl's MCP server sends every diagnostic to stderr, with a prefix:
// --- Logging (MCP uses stdio for transport; all diagnostics must go to stderr) ---
function log(msg: string): void {
process.stderr.write(`[vaultctl-mcp] ${msg}\n`);
}
Concurrent tool calls could start duplicate index builds. A promise lock hands every caller the same build:
async function getOrBuildIndex(): Promise<KnowledgeIndex> {
if (index) return index;
if (indexBuildPromise) return indexBuildPromise;
indexBuildPromise = (async () => {
// ...
index = await buildIndex(adapters);
return index;
})();
try {
return await indexBuildPromise;
} finally {
indexBuildPromise = null;
}
}
Two more fixes from the same release, v2.2.0:
- Wrap each file read in its own try/catch. One corrupt YAML frontmatter block (duplicate keys, for example) could crash the whole vault load.
- On SIGTERM or SIGINT, null the engines and exit after 500 ms. Orphans are a separate job: a LaunchAgent runs every 60 seconds and kills MCP processes whose parent has died, or that are older than four hours.
Page SuiteQL on a unique key
The P&L Report is a NetSuite Suitelet. Its saved search grouped and summed joined lines. Across page boundaries, 231 grouped rows came back duplicated. The search version merges them by a five-field key after the fetch.
A SuiteQL twin, written the same day, avoids the duplicates instead. Its first query grouped in SQL. That needs an ORDER BY over all five grouped columns. Without it, pages overlapped and dropped rows. By that evening the query joined three tables on a compound key, ordered by something unique per row, and summed in JavaScript:
// Three-table JOIN: transactionaccountingline has amount+account+accounttype,
// transaction has postingperiod, transactionline has class+department.
// Compound join (transaction + linesequencenumber) prevents fan-out.
// GROUP BY not supported across 3 JOINs in SuiteQL — aggregate in JS.
var sql =
'SELECT BUILTIN.DF(tal.account) AS account, ' +
'BUILTIN.DF(tal.accounttype) AS accounttype, ' +
'BUILTIN.DF(tl.class) AS classname, ' +
'BUILTIN.DF(t.postingperiod) AS period, ' +
'BUILTIN.DF(tl.department) AS department, ' +
'tal.amount ' +
'FROM transactionaccountingline tal ' +
'INNER JOIN transaction t ON tal.transaction = t.id ' +
'INNER JOIN transactionline tl ON tal.transaction = tl.transaction ' +
'AND tal.transactionline = tl.linesequencenumber ' +
'WHERE tal.posting = \'T\' ' +
// ...
// ORDER BY transaction+line for deterministic pagination (unique per row)
var pagedSql = sql + ' ORDER BY tal.transaction, tal.transactionline' +
' OFFSET ' + offset + ' ROWS FETCH NEXT ' + pageSize + ' ROWS ONLY';
Joined on the transaction alone, each accounting line would match every line of its transaction. The second condition pins it to its own line.
Deduplicate before you fan out
A Forge dashboard of Jira Plans resolved the issue sources behind every plan. Plans share sources. That step made about 350 calls before deduplication and about 20 after. Collect the unique sources first, resolve each once, ten at a time, then map the results back:
async function resolveUniqueSources(planDetails, errors) {
// Collect all raw sources, deduplicate by type:value
const uniqueMap = new Map();
for (const pd of planDetails) {
for (const s of pd.rawSources) {
const key = `${s.type}:${s.value}`;
if (!uniqueMap.has(key)) uniqueMap.set(key, s);
}
}
const unique = [...uniqueMap.values()];
const resolved = new Map();
// Resolve in batches
for (let i = 0; i < unique.length; i += MAX_CONCURRENT) {
// ...
}
return resolved;
}
With deduplication in its four-phase enrichment pipeline, a dashboard load fell from about 590 API calls to about 260.