An open format for AI conversations — spec v1 · curt-format@1.2.0 · MIT
One file, one conversation, kept whole.
A .curt file is UTF-8 JSON — the whole thread in one
file. Images and tool calls ride verbatim, unknown fields survive
foreign readers, and a corrupted file is rejected whole, never
half-imported. Born in Curtis AI Chat, owned by no one.
npm install curt-format
The envelope, and what rides inside.
A .curt file is UTF-8 JSON, tab-indented by convention.
The generator field lets a writer sign its work —
readers must ignore it: presence, absence, or value never affects
parsing. Declared capabilities ride in extensions.
{
"curt": "curtis-conversation",
"version": 1,
"generator": "your-app@1.2.3",
"extensions": ["images-inline", "usage-metadata"],
"conversation": {
"id": "conv_001",
"title": "Thursday's thread",
"createdAt": 1760000000000,
"updatedAt": 1760000600000,
"messages": [
{ "id": "m1", "role": "user",
"content": "hello", "timestamp": 1760000000000 },
{ "id": "m2", "role": "assistant",
"content": "hi back", "model": "…",
"timestamp": 1760000001000 }
]
}
}
The envelope, field by field:
| field | required | meaning |
|---|---|---|
| curt | yes | magic — must be exactly "curtis-conversation" |
| version | yes | format version; this spec is 1 |
| generator | no | provenance, "writer-name@version" — readers must ignore |
| extensions | no | names from the registry — never a validation input |
| conversation | yes | id, title, createdAt, updatedAt, messages |
| role | yes | user · assistant · system · tool |
| model | no | provider model id, typically on assistant messages |
Writers may attach extra fields anywhere — tokens, images, tool-call linkage — and readers must preserve them on round-trip where possible and never fail on them. Full field tables live in the spec; a non-normative JSON Schema ships for tooling. The reference implementation is TypeScript, zero dependencies:
import { serializeCurt, parseCurt, curtFilename } from 'curt-format';
const body = serializeCurt(conversation, 'my-app@1.0.0');
const back = parseCurt(body); // CurtConversation | null — null means invalid
const name = curtFilename(back!); // 2025-10-09 my-thread 001.curt
Strict by design.
A READER ACCEPTS A FILE WHEN, AND ONLY WHEN
-
01
The body parses as JSON — after ignoring at most one leading UTF-8 BOM, because Windows editors prepend one anyway.
-
02
curtis exactly"curtis-conversation"andversionis exactly the number1. -
03
The conversation carries
idandtitleas strings, timestamps as numbers, and amessagesarray in which every entry passes message validation.
Anything else is a rejection — whole-file, not a best-effort parse.
Many conversations travel as a zip of .curt files.
Version 1 never changes meaning; later revisions only clarify, so
version-1 files stay readable forever.
Grows by extension, not by version.
Writers declare named capabilities in a top-level
extensions array. A reader that doesn't know one
ignores it and never rejects over it. An entry moves
draft → stable when two independent
implementations round-trip it. The registry is additive by rule:
new entries never change how existing v1 files parse.
| name | status | purpose | emitted by |
|---|---|---|---|
| images-inline | stable | image payloads ride inside content | plugin · porter · web · cloud · ai-tech |
| usage-metadata | stable | per-message tokens, cost, provider | porter · web · ai-tech |
| tool-linkage | draft | tool calls linked to their results | porter · ai-tech |
| agent-context | draft | agent and swarm provenance | plugin · porter · cloud · ai-tech |
| consensus-receipt | draft | signed k-of-n consensus bound to the transcript evidence | tractum |
Full field definitions per entry: EXTENSIONS.md.
Graded, not claimed.
Implementations run executable vectors from conformance/ and claim the levels they pass — self-reported, but reproducible from the vectors. The reference implementation runs them in CI and passes L1–L4.
- L1 · CORE must Magic and version exact-match, whole-file rejection, one leading BOM tolerated, unknown fields preserved on round-trip, any JSON-number timestamp accepted.
-
L2 · NAMED
should
curtFilenameimplements the<date> <slug> <suffix>.curtalgorithm exactly. -
L3 · BULK
may
The zip-of-
.curtconvention: entry names fromcurtFilename, readers never depend on names, corrupted entries reported rather than silently dropped. - L4+ · EXTENSION may Per-extension vectors — an extension's presence or malformation never causes rejection.
Ecosystem status — who is in, who passes what, where conformance has drifted — is tracked in CAPABILITIES.md, audited 2026-10-14.
Who reads and writes it today.
| consumer | role | r · w | carries |
|---|---|---|---|
| curtis plugin | import · export · backup | R + W | vendored copy |
| porter | converter + verify oracle | R + W | vendored oracle |
| curtis-web | export endpoint | W | hand-rolled |
| curtis-cloud | shared library | R + W | vendored mirror |
| curtis-ai-tech | embedded converter + docs | R + W | inline copy |
| tractum | consensus emitter | W | consensus-receipt draft |
None depend on the curt-format package yet — every one
vendored or hand-rolled the rules. The ladder from five copies to
one dependency is written down in the audit.
The npm package is documentation, not infrastructure. Every consumer today vendors or hand-rolls the rules, so a spec revision still lands in five places by hand.
The 1.1.0 BOM tolerance is unimplemented outside the reference
reader, and three filename conventions compete with the spec's
curtFilename.
Bindings beyond TypeScript. The predicates are small — Rust, Python, Go, Swift: pick one and keep it in lockstep with SPEC.md. PRs to dot_curt are welcome.