spec
id: "spec" slug: "spec" title: "The .curt format — SPEC" description: "The normative .curt v1 specification: file shape, validation rules, extensions, bulk convention, versioning."
This document is the normative specification of the .curt format. When any
other page, schema, or prose disagrees with it, this document — and the
reference implementation parseCurt — decide.
The validation rules of format version 1 are frozen at release. Within version 1 the format moves only through the extension registry; anything that would change how a conforming v1 reader parses requires a new format version.
.curt is a single-file conversation format for AI chats: one file, one
conversation, full fidelity. It is designed so a portable file is byte-robust —
no escaping edge cases, base64 images and tool calls survive verbatim — and so
a reader can validate strictly: a corrupted file is rejected whole, never
half-imported.
The magic string "curtis-conversation" is a version-1 compatibility legacy
of the format's origin in Curtis AI Chat; the format itself is
vendor-neutral, and nothing in this spec favors Curtis.
Reference implementation: src/index.ts (TypeScript, zero
dependencies, MIT). Third-party writers are welcome; the generator field
exists so your app can sign what it wrote. A non-normative
JSON Schema is provided for tooling.
Optional capabilities grow through the extension registry;
implementations are graded against executable vectors in
conformance/.
File shape
A .curt file is UTF-8 JSON (tab-indented by convention) with this shape.
Readers must ignore at most one leading UTF-8 byte-order mark (EF BB BF) —
it is not part of the JSON body, and writers must not emit it:
{
"curt": "curtis-conversation",
"version": 1,
"generator": "your-app@1.2.3",
"conversation": { ... }
}
Top-level fields
| Field | Required | Meaning |
|---|---|---|
curt | yes | Magic field. Must be exactly "curtis-conversation". |
version | yes | Format version. This spec is 1. |
generator | no | Provenance: "writer-name@version". Readers must ignore it — presence, absence, or value never affects parsing. |
conversation | yes | The conversation object (below). |
The conversation object
| Field | Required | Meaning |
|---|---|---|
id | yes | Stable conversation identifier (string). Writer's choice of scheme. |
title | yes | Human-readable title (string, may be empty). |
createdAt | yes | Epoch milliseconds (number). |
updatedAt | yes | Epoch milliseconds (number). |
messages | yes | Array of message objects (below). An empty array is spec-valid — readers accept it; writers should not emit one, since a conversation with no messages carries nothing. |
Each message in messages
| Field | Required | Meaning |
|---|---|---|
id | yes | Stable message identifier (string). |
role | yes | One of "user", "assistant", "system", "tool". |
content | yes | Message text (string). Binary payloads ride inside content as base64/data URIs, verbatim. |
timestamp | yes | Epoch milliseconds (number). |
model | no | Provider model id, typically on assistant messages. |
Timestamps are epoch milliseconds. Writers should emit integers; version-1 validators accept any JSON number and must not reject a file over fractional or negative values.
Unknown fields
Writers may attach extra fields to the top level, the conversation, and messages (tokens, images, tool-call linkage, …). Readers must preserve them on round-trip where possible and must not fail on them.
Extensions
Writers may declare named extensions they use via an optional top-level
extensions array:
{
"curt": "curtis-conversation",
"version": 1,
"extensions": ["images-inline"],
"conversation": { "..." : "..." }
}
- Writer obligation: when present, the array holds non-empty strings
naming entries in
EXTENSIONS.md— the registry. - Reader obligation: the
extensionsfield is never a validation input and never a reason to reject. A reader that does not implement a declared extension ignores its name and reads only the fields it knows; a malformedextensionsvalue (wrong type, wrong shape) is ignored like any other unknown field. - Extension behavior lives in the fields an extension reads and writes
inside
conversationand its messages; the registry entry documents those fields. An extension must not change how the core validates. - The registry is additive: new entries never change how existing v1 files parse. Extensions cannot conflict with core field names.
Validation rules (version 1)
A reader accepts a file as .curt v1 when, and only when:
- The body parses as JSON (after ignoring at most one leading UTF-8 BOM).
curtis exactly"curtis-conversation"andversionis exactly the number1— a string"1"is a rejection.conversationhasid/titleas strings,createdAt/updatedAtas numbers, andmessagesas an array in which every entry passes message validation.
Anything else is a rejection (whole-file), not a best-effort parse. The
reference parseCurt is the canonical behavior; isCurtMessage and
isCurtConversation expose its component predicates for reuse.
Conformance
A conforming writer emits files that pass the validation rules above,
keeps version at 1, signs generator as "writer-name@version", and
never writes a BOM.
A conforming reader rejects whole-file on any validation failure,
ignores generator entirely, and preserves unknown fields on round-trip
where possible. Readers that re-serialize must produce a body their own
parser accepts.
JSON Schema
schema/curt-v1.schema.json is a non-normative
draft-07 rendering of the validation rules, for editors, linters, and
third-party tooling. The normative rules are this document; when in doubt,
parseCurt decides.
Bulk convention
Multiple conversations travel as a zip of .curt files. There is no required
naming scheme; readers must not depend on file names. Readers import the valid
entries and report (not silently drop) corrupted ones; directory entries, if
present, are ignored. The Curtis convention is
"<YYYY-MM-DD> <slug> <suffix>.curt" for chronological, collision-safe
ordering, defined so the name depends only on immutable fields (createdAt,
id) and therefore survives title edits and later updates:
<date>—createdAtas a UTCYYYY-MM-DD;1970-01-01whencreatedAtis not a finite timestamp.<slug>—titlelowercased, each run of letters or digits joined with-, capped at 50 characters with trailing hyphens trimmed;untitledwhen nothing remains.<suffix>— the part ofidafter the last_; if there is no_, or nothing follows it, the last 8 characters ofid.
Example: 2025-10-09 fixture-conversation 001.curt. The reference
implementation exposes this as curtFilename.
Implementation notes
Treat .curt files as untrusted input. Images ride inline as base64 inside
content, so a small conversation can be a very large file — bound parsing
by input size before validation. Unknown fields are data, never code: do not
interpret, dereference, or execute anything found in them.
Versioning
Format versions change rarely. Within a version:
- Additions ship as new
EXTENSIONS.mdregistry entries or as clarifications.versionstays the same and existing files remain valid. - Breaking changes — anything that would make a conforming v1 reader
reject or misparse a file it previously accepted — get a new version
number. A future version 2 bumps
versionand keeps version-1 files readable forever. Breaking changes are never a silent redefinition of 1.
The validation rules of a version are frozen at its release; the registry is the only forward-motion mechanism inside version 1.