# Write a bundle

To put a meeting into Chitmonk, write a `.chit` and have the person import it: the library drawer, then **Import**. There is no other way in, and no way to do it without them.

## The smallest valid bundle

Two files in a ZIP.

`manifest.json` lists one meeting, with the SHA-256 of its file:

```json
{
  "format": "chitmonk-bundle",
  "version": 1,
  "exportedAt": "2026-10-09T16:00:00.000Z",
  "app": "my-tool 1.0",
  "meetings": [{ "id": "<meeting-id>", "path": "meetings/<meeting-id>.json", "schema": 1, "events": 3, "sha256": "<sha-256 of that file>" }]
}
```

`meetings/<meeting-id>.json` holds the events. A meeting needs only its first one, `MeetingCreated`, to be valid. A useful one also has people, lines and an end:

```text
MeetingCreated        the title, the language, what made the transcript
ParticipantAdded      one per person
RecordingStarted
SegmentCommitted      one per line
MeetingEnded
```

## A writer in Python

No packages to install. Change `TITLE`, `PEOPLE` and `LINES` to what you have.

```python
"""Write a .chit bundle Chitmonk will import, from a transcript you already have.

Standard library only.    python3 write_chit.py planning-call.chit
"""
import datetime
import hashlib
import json
import sys
import time
import uuid
import zipfile

TITLE = "Planning call"
PEOPLE = ["Ana", "Ben"]
# start and end in seconds from the start of the recording, who your tool thinks spoke (or None), the words
LINES = [
    (0.0, 4.2, "Ana", "Shall we ship the import on Thursday?"),
    (4.8, 7.5, "Ben", "Yes. I will test it on the shared laptop first."),
    (8.0, 9.4, None, "Sounds like a plan."),
]


def new_id():
    return str(uuid.uuid4())  # any lowercase UUID will do


meeting = new_id()
started = int(time.time() * 1000)  # when the recording began, in milliseconds since 1970
events = []


def add(kind, at, **fields):
    events.append({"id": new_id(), "meetingId": meeting, "at": at, "type": kind, **fields})


# The first event must be MeetingCreated. `engine` is printed on the receipt as "Transcribed by",
# so say what really made the transcript.
add("MeetingCreated", started, schema=1, title=TITLE, language="en", privacyMode="transcript-only",
    engine={"stt": "My transcriber", "sttVersion": "1.0", "runtime": "Python", "vad": "none", "speaker": None},
    vocabulary=[])
people = {name: new_id() for name in PEOPLE}
for name, pid in people.items():
    add("ParticipantAdded", started, participantId=pid, name=name)
add("RecordingStarted", started)
for start, end, who, text in LINES:
    # suggestedSpeakerId is a guess: Chitmonk shows it dashed, with a "?", until a person confirms it.
    # If a person really did say who spoke, follow the line with a SpeakerAssigned event instead.
    add("SegmentCommitted", started + round(end * 1000), lineId=new_id(), text=text, words=[],
        startMs=round(start * 1000), endMs=round(end * 1000), language="en", turnId=new_id(),
        suggestedSpeakerId=people.get(who))
add("MeetingEnded", started + round(LINES[-1][1] * 1000))

# One file per meeting, and a manifest that lists it with the SHA-256 of its exact bytes.
body = json.dumps({"schema": 1, "events": events}).encode()
path = f"meetings/{meeting}.json"
manifest = {
    "format": "chitmonk-bundle", "version": 1,
    "exportedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
    "app": "write_chit.py 1.0",
    "meetings": [{"id": meeting, "path": path, "schema": 1, "events": len(events), "sha256": hashlib.sha256(body).hexdigest()}],
}
# Only these two files. No folders as entries of their own, nothing else.
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as z:
    z.writestr("manifest.json", json.dumps(manifest, indent=2))
    z.writestr(path, body)
print(f"wrote {sys.argv[1]}: {len(events)} events")
```

## Rules that trip people up

- **Ids are lowercase UUIDs**, hyphenated. Uppercase is refused. Give every event, line, person and item its own.
- **Every event carries the same `meetingId`**, and it is the id in the manifest and in the file's name.
- **`MeetingCreated` comes first**, with `schema: 1`.
- **The checksum is of the exact bytes in the ZIP.** Hash the bytes you write, not a re-serialised copy.
- **Only the listed files.** No folder entries, no `__MACOSX`, no readme. Zip the files, not the folder they are in.
- **Times.** `at` is milliseconds since 1970. `startMs` and `endMs` are milliseconds from the start of the recording. The meeting's length comes from the `at` of `RecordingStarted` and `MeetingEnded`, so set those apart by the length of the recording.
- **`words` may be an empty list.** Chitmonk fills it with word timings when it transcribes; nothing needs it on import.
- **`language`** is a short code. Chitmonk's own are `en`, `de`, `fr`, `es`, `it`, `nl`, `pl`; on a meeting it may also be `auto`.
- **Text is capped** at 20,000 characters for any one title, line or note.
- **No voice data.** A key named `embedding`, `embeddings`, `voiceprint`, `prototype`, `pcm` or `audio` anywhere in an event makes the whole bundle fail.

The [JSON Schema](/chit.schema.json) says all of this in a form a validator can check.

## Do not confirm what a person did not

This is the one rule that is about honesty and not about format.

- If **your tool** worked out who spoke, put it in `suggestedSpeakerId`. Chitmonk shows it dashed with a question mark, and the person confirms it with one key.
- If **a person** said who spoke, follow the line with `SpeakerAssigned`.
- If your tool found an action item, write `ActionSuggested` and stop. `ActionAccepted` means a person agreed to it.

Chitmonk never turns a guess into a fact by itself, and a bundle should not do it on its behalf.

## What happens on import

The bundle is checked before anything is saved: [what import checks](/chit/#what-import-checks). If the person already has a meeting with the same id and different events, yours is added as a separate copy; it never overwrites theirs. Use a new id for a new meeting.
