niles is a local-first CRM CLI for relationship work. It manages contacts,
interaction notes, follow-up tasks, teammates, materials, surveys, human intake,
and reviewed model recommendations. Every command returns a machine-readable
JSON envelope, making Niles safe for people and coding agents to operate.
This guide follows one running example: attorney Lionel Hutz is looking for new clients around Springfield while keeping promises to his existing clients.
Docs: https://expectedparrot.github.io/niles/
License: MIT. The code and bundled Niles artwork are MIT licensed.
Copy this block into an agent session:
You are working with Niles, a local-first CRM CLI for relationship work.
Install Niles with EDSL support:
python -m pip install "niles[edsl] @ git+https://github.com/expectedparrot/niles.git"
Run commands from the directory that should contain the CRM. Your first
command—both for a new project and whenever you resume work—is:
niles agent next
Follow the JSON envelope it returns. Check `status`, read `errors` on failure,
and use the returned `next_steps` and stable identifiers. If the project has
not been initialized, the envelope will direct you to run `niles init`.
Read and mutate CRM data only through `niles` commands. Niles manages its own
storage and exchange artifacts. Use `niles sync` for commits and pushes; do
not inspect, edit, or stage Niles internal storage.
Niles makes no EP network calls and reads no EP credentials. For intake and
status requests, use Niles `export`, run the returned `publish_command`, use
Niles `register`, run the returned `pull_command`, then use Niles `import` and
`review`. For recommendations, use Niles `recommend export`, run the returned
`run_command`, then use `recommend import` and `recommend review`.
Treat imported human responses and recommendations as quarantined until an
explicit accept, merge, or reject command records the review decision. Never
store credentials in CRM state or git history.
Core CRM features require Python 3.11 or newer:
python -m pip install "niles @ git+https://github.com/expectedparrot/niles.git"
niles versionInstall the EDSL extra for humanized forms and recommendation jobs:
python -m pip install "niles[edsl] @ git+https://github.com/expectedparrot/niles.git"
python -c "import edsl; print(edsl.__version__)"
ep --helpThe ep commands that publish or retrieve Expected Parrot data require
EXPECTED_PARROT_API_KEY. Niles itself never authenticates with Expected
Parrot or makes network calls. Never put API keys in CRM state or git history.
mkdir hutz-law
cd hutz-law
niles init
niles agent next
niles contact add "Burns Industries" \
--tag prospect \
--trait source=ambulance-adjacent-referral \
--trait priority=1 \
--cadence-days 14
niles contact add "Waylon Smithers" \
--company "Burns Industries" \
--role "Executive Assistant" \
--email smithers@burns.example \
--tag decision-maker
niles note add burns-industries \
"Discussed workplace liability. Smithers requested an engagement outline." \
--kind call
niles task add burns-industries \
"Send engagement outline before Mr. Burns loses interest" \
--due 2026-09-05 --assign lionel --tag next-step
niles contact show burns-industries --with-notes --with-tasks
niles task list --status open --assignee lionel
niles statusNiles is event-sourced. These entities describe the current SQLite projection; the durable records are the events that created or changed them.
A contact can represent a person or organization. Only name is required.
Use a person tag for people and a company, organization, or account tag
for organizations. The equivalent entity_type trait is useful during mapped
imports. Reports honor explicit types first, then fall back to company,
pipeline stage, and pipeline tags for legacy records. Ambiguous untyped records
become cleanup warnings instead of presumed pipeline accounts.
| Field | Meaning |
|---|---|
id |
Stable generated identifier with a con_ prefix |
slug |
Lowercase name-based reference, such as burns-industries |
name |
Display name |
emails, phones |
Lists of contact points |
company, role |
Optional relationship context |
traits |
Open-ended string, number, or boolean attributes |
tags |
Free-form workflow labels |
cadence_days |
Desired maximum interval between interactions |
archived |
Soft-deletion state |
created_at |
UTC creation timestamp |
last_touched |
Derived from the most recent note |
contact list --stale returns cadence contacts whose last note is old enough,
plus cadence contacts that have never been touched.
References may be exact IDs, emails, slugs, or unique fuzzy name/company matches. Ambiguous references fail and return candidates; mutations never guess.
Notes are interaction records attached to contacts.
| Field | Meaning |
|---|---|
id |
Stable note_ identifier |
contact_id |
Owning contact |
created_at |
Interaction timestamp |
event_sequence |
Monotonic append order used to break timestamp ties |
kind |
note, call, meeting, email, intake, debrief, or enrichment |
text |
Note contents |
source |
Provenance, such as user |
| Field | Meaning |
|---|---|
id |
Stable task_ identifier |
contact_id |
Related contact |
assignee |
Owner name or alias |
due_date |
Optional ISO date |
text |
Action to perform |
status |
open, done, blocked, or cancelled |
tags |
Workflow labels |
source |
Provenance |
done_note |
Completion or cancellation explanation |
- A teammate has an ID, name, aliases, optional email, and role. Tasks store assignee text, so aliases serve as useful conventions.
- Organization context stores one project-wide name, description, and traits.
- A material is a titled local path or URL with description and tags.
- A survey is a versioned question list plus deterministic routing rules.
- A form is a local registration for a remote intake or status-request survey.
- A submission contains quarantined answers and a review status.
- A recommendation contains a proposed task, rationale, source path, and review status.
Submission states are pending, accepted, merged, or rejected.
Recommendation states are pending, accepted, or rejected. Pulling or
importing data never mutates relationships; explicit review is the mutation gate.
This is an implementation detail: normal use should go through niles
commands, including niles sync. You do not need to inspect, stage, or name
anything in this directory.
.niles/
manifest.json project identity and storage contract
config.toml local format configuration
.gitignore excludes the derived index
events/ append-only JSON events: source of truth
surveys/ versioned survey definitions
reports/ generated reports you may choose to commit
index/niles.sqlite disposable SQLite projection and FTS index
Each mutation appends a numbered JSON event with a schema version, event ID, sequence, timestamp, type, and payload. SQLite is a cache, not an agent API.
niles rebuild-index
niles fsckfsck validates manifests, JSON, schemas, ordering, filenames, duplicate IDs,
supported types, replay, and orphaned records. Never edit events or query the
SQLite projection directly.
Undo is compensating and append-only:
niles history --contact burns-industries
niles undo <event-id>The original remains; Niles appends event_reverted and rebuilds without that
mutation. Undo dependent events before undoing their contact creation.
Every command writes exactly one envelope. Success exits zero:
{
"schema_version": "niles.envelope.v1",
"status": "ok",
"command": "contact add",
"argv": ["contact", "add", "Burns Industries"],
"data": {},
"warnings": [],
"errors": [],
"next_steps": []
}Failures exit nonzero and use stable codes in errors[]:
{
"status": "error",
"data": {"candidates": []},
"errors": [
{"code": "unknown_contact", "message": "No contact matched 'monorail'."}
]
}Suggested next steps declare whether they mutate state, use the network, or
require approval. Check status before reading data.
niles contact add "Krusty Burger" --tag prospect --cadence-days 30
niles contact add "Krusty the Clown" --company "Krusty Burger" --role Founder
niles contact show krusty-burger --with-notes --with-tasks
niles contact list
niles contact list --tag prospect
niles contact list --stale
niles contact update krusty-burger \
--role "Potential class-action defendant" \
--email legal@krusty.example \
--phone 555-0113 \
--trait lead_quality=questionable
niles contact tag krusty-burger --add active-client --remove prospect
niles contact archive krusty-burger --reason "Cease-and-desist received"
niles contact merge waylon-smithers smithers --note "Duplicate from intake"
niles contact status burns-industries "Waiting on signed engagement letter" --at 2026-09-02Merging moves notes and tasks to the kept contact and archives the duplicate.
contact status records a dated status note and an explicit current_status;
the explicit value takes precedence over inferred note text in reports.
niles note add burns-industries "Initial consultation" --kind meeting
niles note add burns-industries "Demand letter sent" --kind email --at 2026-09-02
niles note list burns-industries --limit 10
niles note list --limit 25
niles enrich ingest burns-industries \
"Burns Industries announced a nuclear safety initiative." \
--source-url https://example.com/source --confidence 0.8Research happens outside Niles; enrich ingest records reviewed findings.
niles task add burns-industries "Draft engagement letter" \
--due 2026-09-05 --assign lionel --tag urgent
niles task list --status open
niles task list --status open --assignee lionel
niles task list --contact burns-industries
niles task list --due today
niles task update <task-id> \
--text "Draft discounted engagement letter" \
--due 2026-09-06 --assign selma --status blocked \
--tag waiting-on-retainer
niles task reassign <task-id> lionel
niles task done <task-id> --note "Slid under office door"
niles task cancel <task-id> --note "Client fled jurisdiction"
niles task suggest --assignee lioneltask suggest returns suggestions for contacts with context but no open task;
it does not create tasks.
niles teammate add "Lionel Hutz" --alias lionel --alias hutz \
--email lionel@hutz.example --role Attorney
niles teammate add "Selma Bouvier" --alias selma --role "Office manager"
niles teammate list
niles teammate show lionel
niles org context set \
"Hutz Law handles personal injury, contracts, and matters of negotiable merit." \
--name "Hutz Law" --trait jurisdiction=Springfield
niles org context show
niles material add "Standard engagement letter" \
--path templates/engagement-letter.pdf --tag onboarding
niles material add "Fee schedule" --url https://hutz.example/fees --tag sales
niles material list
niles material list --tag onboardingMaterials require --path or --url.
Search uses SQLite FTS5 across contacts, notes, traits, tags, and tasks:
niles search "workplace liability"
niles search "engagement letter"
niles status
niles agent next
niles history
niles history --contact burns-industries --limit 20
niles report status --html hutz-status.htmlThe HTML report is an operating view, not a chronology dump. It leads with accounts closest to revenue, actions grouped by owner, stalled or waiting accounts, and an active pipeline with stage, priority, mapped people, latest interaction, current status, and next action. Won/lost/dead accounts and full history are collapsed. Missing stages, relationship roles, actions, owners, and due dates appear as explicit cleanup warnings. CRM content is escaped before rendering.
See the published Hutz Law operating report on GitHub Pages. Its reproducible fictional CRM source builds the project entirely through public commands and includes contracting deals, a pilot, a stalled account, a warm introduction, lost accounts, mapped people, commercial values, materials, and intentional cleanup warnings:
./examples/hutz-law-crm/populate.sh /tmp/hutz-law-demoOpen /tmp/hutz-law-demo/crm-operating-report.html to exercise its search,
stage filter, sortable tables, and expandable relationship history.
CSV imports are previews unless --commit is present:
niles import csv springfield-leads.csv
niles import csv springfield-leads.csv --commit
niles export csv --output contacts.csv
niles export json --output contacts.json
niles export csv --tag prospect --output prospects.csvRecognized identity fields are name, email or emails, company, role,
tags, and cadence_days. Operational mappings can promote spreadsheet data
into entity_type, stage, priority, current_status, champion, connector,
deal_value, expected_mrr, next_action, owner, due_date, and
last_interaction. A paired material_title and material_url creates a
real material. Multiple emails and tags use semicolons. TOML maps external
headers:
[columns]
"Potential Plaintiff" = "name"
"Last Known Employer" = "company"
"Legal Emergency" = "tags"
"Case Stage" = "stage"
"Current Status" = "current_status"
"Next Action" = "next_action"
"Action Owner" = "owner"
"Action Due" = "due_date"
"Last Real Interaction" = "last_interaction"niles import csv courthouse-steps.csv --mapping hutz-mapping.toml
niles import csv courthouse-steps.csv --mapping hutz-mapping.toml --commitniles init installs debrief, review, and intake-basic templates:
niles survey list
niles survey show debrief
niles survey copy debrief client-debriefAnswers are JSON keyed by question name:
{
"summary": "Smithers wants an engagement outline.",
"sentiment": "positive",
"next_step": "Send the outline",
"next_by": "2026-09-05",
"owner": "lionel"
}The closed routing vocabulary is set_field, set_trait, append_note,
create_task, task_due, task_assignee, add_tag, archive, and noop.
Unknown actions and missing question references fail validation.
niles survey run client-debrief \
--contact burns-industries --answers debrief-answers.json --dry-run
niles survey run client-debrief \
--contact burns-industries --answers debrief-answers.json
niles survey export-edsl client-debrief --output client-debrief-edsl.jsonWithout --answers, survey run returns the definition and
requires_answers: true. EDSL export is local and makes no network request.
The EP boundary is strict: Niles exports and records; ep publishes and pulls.
First export an EDSL survey without making a network request, then let ep
humanize it:
niles intake export intake-basic
# Run the publish_command returned above.
niles intake register intake-basicregister records the UUID and URLs returned by EP. It does not contact EP.
Niles manages the exchange files and returns the exact EP command at each
boundary. Use the returned local form ID to connect later response imports:
# Run the pull_command returned by register.
niles intake import <local-form-id>
niles intake status
niles intake review
niles intake review <submission-id> --accept
niles intake review <submission-id> --merge burns-industries
niles intake review <submission-id> --reject --note "Prank call from Bart"Imports accept EDSL Results .ep files or Results JSON. They are quarantined
and deduplicated. Acceptance creates a client and applies allowed routes; merge
attaches routed information to an existing client; rejection preserves the
audit record without changing relationships.
Use an explicit response path only when importing a file obtained elsewhere:
niles intake import <local-form-id> downloaded-responses.json.
Intake surveys cannot archive contacts or set protected fields. Close currently
closes the local registration; the remote API lacks non-destructive close, so
the envelope reports remote_closed: false (Coopr issue #3950).
niles status-request export client-debrief
# Run the returned publish_command.
niles status-request register client-debrief \
--contact burns-industries --recipient smithers@burns.example
# Run the returned pull_command.
niles status-request import <local-form-id>
niles status-request status
niles status-request review
niles status-request review <submission-id> --accept
niles status-request review <submission-id> --reject --note "Unverified update"Accepted answers use deterministic survey routing. Rejected answers remain in history and cause no CRM mutation. As with intake, Niles performs no publishing, pulling, authentication, or other network activity.
Build an EDSL survey for the active sales pipeline (the default scope):
niles human-update --output update_job.epSelect another entity type or compose filters when the review has a different purpose:
niles human-update --scope people --output relationship_update.ep
niles human-update --scope organizations --tag prospect \
--stage contracting --output contracts.ep
niles human-update --scope all --entity-id <contact-id> \
--include-archived --output selected.epScopes are pipeline, people, organizations, and all. Repeated tags are
combined with AND; repeated stages and explicit entity IDs are combined with
OR inside their respective filter. Archived entities remain excluded unless
--include-archived is present. The JSON envelope and companion manifest both
record the applied scope and filters.
Pipeline surveys classify each row as Current, Follow up, Waiting on them, Waiting on us, Stalled, Won, or Lost / dead; people and general
organization scopes use relationship-appropriate choices instead of sales
stages. Current rows need no more input. Other rows open a focused notes prompt;
nonterminal rows also open a tailored action checklist plus next-action, owner,
and due-date fields. The command is offline and returns a publish_command
such as:
ep humanize create --survey update_job.ep --name "Niles status update"The adjacent update_job.ep.manifest.json records the stable Niles entity ID
for every update question so retrieved answers can be routed without relying on
mutable names. EP—not Niles—publishes the survey and requests responses.
Niles prepares EDSL jobs but never runs models itself:
niles recommend export next-steps --tag prospect
# Run the returned run_command.
niles recommend import --name next-steps
niles recommend review
niles recommend accept <recommendation-id> --assign lionel --due 2026-09-10
niles recommend reject <recommendation-id>Import is quarantined. Acceptance creates one recommendation-tagged task and
preserves the source results path and review provenance.
Git provides sync and provenance, but Niles owns the durable-state path list.
Initialize the project as a git repository once, then use niles sync:
git init
git remote add origin git@github.com:example/hutz-law-crm.git
niles sync --dry-run
niles sync --message "Update Hutz Law CRM"niles sync regenerates a human-readable CRM projection in the repository's
README.md, then stages it with the durable CRM state. The projection shows
the active pipeline, actions, relationship network, and data-quality warnings.
If the README already contains user-written material, Niles preserves it and
owns only the section between <!-- niles:projection:start --> and
<!-- niles:projection:end -->.
Niles never stages the rebuildable index, managed EP exchange files, or
unrelated working-tree files. It commits the durable paths and runs git push.
Do not edit the generated README section directly; change the CRM through
Niles and sync again.
To create a local commit without pushing:
niles sync --no-push --message "Checkpoint Hutz Law CRM"If there are no new durable changes, no commit is created. Push failures are reported as structured errors and include the locally created commit ID.
Portable ZIP archives contain durable state and exclude SQLite:
niles export hutz-law.zip
mkdir hutz-law-restored
cd hutz-law-restored
niles import /path/to/hutz-law.zip
niles fsckImport refuses to overwrite existing state unless explicitly requested:
niles import /path/to/hutz-law.zip --replace- Run commands from the CRM project, not the Niles source checkout.
- Start or resume with
niles agent next. - Check envelope
statusanderrors[]. - Reuse exact IDs, emails, or slugs from prior commands.
- Mutate only through the CLI; never edit events or SQLite.
- Preview CSV imports and survey routes before committing.
- Treat pulled human responses and recommendations as quarantined.
- Never accept or merge quarantined data without a review decision.
- Never store credentials in CRM state.
- Use
niles syncwhen provenance or remote sync is wanted; never stage or manage Niles internal storage yourself.
If a view is missing, that is a Niles feature gap—not a reason to bypass the command layer.
make test
make coverageCoverage follows subprocesses, measures branches, and enforces an 85% floor.
Read SPEC.md before changing command semantics, event shapes, survey routing,
the envelope, or EDSL handoffs.
