AI-native work units with documents and memory

Tickets

Tickets are Flicker's work units, built so a person or an agent can pick up a task from the system of record instead of reconstructing it from chat. Each ticket belongs to a project, can nest under a parent, moves through a fixed workflow, and carries versioned markdown documents and searchable memory.

The v1 workflow

A ticket is always in one of four statuses: backlog, selected_for_dev, in_progress, or done. You don't set the status directly; you use named transitions, so the history stays honest about how work actually moved.

move a ticket through the workflow
$ flicker ticket select-for-dev 42
# backlog → selected_for_dev
$ flicker ticket start 42
# selected_for_dev → in_progress
$ flicker ticket complete 42
# in_progress → done

Versioned documents

Each ticket carries markdown documents, one per kind. The kinds are a fixed set Flicker defines — one for each stage of the workflow — so the reasoning and evidence live next to the work instead of in a lost thread:

  • feature_brief — the problem, who it's for, non-goals, risks
  • plan_consensus — the chosen approach and the alternatives rejected
  • task_contract — the acceptance criteria: what "done" means for this ticket
  • design — UI/product design, when there's a surface to design
  • implementation_notes — what changed, commands run, the PR link
  • review · regression · test_verdict — evidence from the test stage
  • release — the release record

Writes are append-only with a current head per kind, so you always have the latest and the full history. A document can also carry structured fields — a test_verdict of pass or fail, who approved it — that the workflow reads to gate stage transitions (a ticket can't complete without a passing verdict).

write and read a document
$ flicker ticket document write 42 task_contract --body "$(cat plan.md)"
$ flicker ticket document read 42 task_contract --history

Local checkout for agents

Check a project's tickets out as local markdown files so an agent can read and edit them in the working tree, then push changes back. Pushes are guarded against conflicts, so two workers can't silently clobber each other.

check out, edit, push
$ flicker ticket checkout my-project --out .flicker/tickets
# writes .flicker/tickets/000042-implement-checkout.md
$ flicker ticket push .flicker/tickets/000042-implement-checkout.md

Searchable memory

Memory is a searchable record Flicker maintains for the whole project — a Postgres full-text index over your current tickets, their current documents, and the events that recorded how work moved. It's how a worker finds the current contract and the prior evidence for a task without hunting through history.

Every result is labeled with its source_class — a current ticket, a current document, or an event — so an answer is traceable to where it came from, not a guess. Current truth is a ticket's fields and its latest documents; older versions and events are history, evidence of how the work got here. Agents search the same store with /flicker-recall.

Results are also labeled with their temporal state. A record is current when nothing supersedes it, historical when something does — carrying a pointer to what replaced it — and unknown when it was superseded but the replacement can't be read. Superseded results are shown and labeled, not hidden: "what did we decide about this" often has an answer that was later replaced, and dropping it silently would answer the question wrongly. Flicker tracks when a fact was true, not when it was recorded — so this is valid time with provenance, not a bitemporal store.

search the project's memory
$ flicker memory search "checkout conflict" --json
$ flicker memory search "retry policy" --current-only
$ flicker memory write "<what you learned>" --ticket 42

Connections between records

Records are also linked. A ticket that mentions #42 in its body cites ticket 42; a ticket closed as superseded gets a supersedes link from whatever replaced it; a document cites whatever its text mentions. Those links are derived from your own data — the text you wrote, the disposition you chose — not inferred by a model, and they carry no confidence score because they are not guesses.

Search finds records that share words with your query. Expand finds records that share an edge with those. That difference is the point: "why did we do X" often reaches the decision that caused X through a link, not through vocabulary the two happen to have in common.

walk the connections around a record
$ flicker memory expand ticket 1274 --depth 2

Direction is always stated. "Cites" and "cited by" are different claims about the same pair, and Flicker never merges them into "related".

i

What this is not

Flicker records when a fact was true, not when it was written down — valid time with provenance, not a bitemporal store, and there is no as-of-recording axis to query. Links are structural: nothing reads your prose and invents relationships, and no knowledge graph is extracted by a model. Ranking is Postgres full-text search; the graph adds reachability, not a different relevance score.

Suggestions: end-user reports that become tickets

A suggestion is a bug report or feature request one of your users sent from your app. The recommended wiring is flicker suggestions enable --app <app>, which injects a project-scoped FLICKER_API_KEY plus FLICKER_SUGGESTIONS_PROJECT_ID at deploy. Your backend forwards the report with that credential, and it lands in the project as an open suggestion you can triage into a real ticket. An org API token still works for a server you do not deploy on Flicker.

!
Call this from your server, never from an end user's device. A Flicker API token is org-wide — shipping one in a mobile or browser client hands out your whole organization. The injected suggestions credential is scoped to a single project's suggestion routes, so an app that only proxies reports no longer needs an org-wide token.

An org-key request to PATCH /api/v1/projects/:id with {"suggestions_ai":"enrich"} switches AI enrichment on for that project. off is the default. enrich is advisory only: it writes a summary and lists possible duplicates, and never links anything. triage does that and writes to your tracker — it may attach the report to an existing ticket or open a new one, which emits the accepted event your app polls to notify the person who reported it. Below its confidence floor it attaches nothing and creates nothing. investigate is reserved and unimplemented.

submit a suggestion
$ curl -s https://flickercloud.com/api/v1/projects/my-app/suggestions \
  -H "Authorization: Bearer $FLICKER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Crashes on save","kind":"bug","source":"ios",
       "reporter_external_id":"user-42","submission_id":"abc-123"}'

submission_id is your idempotency key. Send the same one twice and you get 200 with the suggestion that already exists ("duplicate": true) instead of a second copy — safe to retry from a flaky mobile network.

Bug reports may attach one optional screenshot as base64 inside the same JSON body (not multipart). Only kind: "bug" is accepted; feature requests and other kinds reject the screenshot with 422 screenshot_requires_bug_kind. Allowed types are image/png, image/jpeg, and image/webp; decoded size is capped at 4 MiB — oversize is rejected (422 screenshot_too_large), never truncated. Bodies above the JSON parser limit (~8 MB) fail as 413 before application code runs. Screenshots live in a private bucket and are never publicly addressable; fetch them with the org-wide key via GET /api/v1/suggestions/:id/screenshot, which 302s to a signed URL valid for 15 minutes and served Cache-Control: no-store. Reporter delete and the by-reporter purge remove the stored image as well as the row. Screenshots on resolved/rejected reports are deleted after 30 days (the row survives).

submit a bug with a screenshot
$ curl -s https://flickercloud.com/api/v1/projects/my-app/suggestions \
  -H "Authorization: Bearer $FLICKER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Crashes on save","kind":"bug","source":"ios",
       "reporter_external_id":"user-42","submission_id":"abc-123",
       "screenshot":{"content_type":"image/png","data":"<base64>"}}'
$ curl -si https://flickercloud.com/api/v1/suggestions/7/screenshot \
  -H "Authorization: Bearer $FLICKER_TOKEN"

A suggestion is always open, accepted, resolved, or rejected. As with tickets you don't set the status — you send an action to PATCH /api/v1/suggestions/:id: accept (with a ticket_id, which also writes a suggestion.linked event on the ticket), resolve, or reject (with a rejection_reason). Completing the linked ticket resolves every accepted suggestion pointing at it — each one gets its own resolved event, so a single fix notifies every reporter.

triage, then follow the feed
$ curl -s -X PATCH https://flickercloud.com/api/v1/suggestions/7 \
  -H "Authorization: Bearer $FLICKER_TOKEN" \
  -d action=accept -d ticket_id=42
$ curl -s "https://flickercloud.com/api/v1/projects/my-app/suggestion-events?after_id=0" \
  -H "Authorization: Bearer $FLICKER_TOKEN"

GET /api/v1/projects/:project/suggestion-events is an append-only feed: poll it with ?after_id=N and you get everything since that event, in order, so your app can tell a reporter their report was accepted or resolved. List suggestions with GET /api/v1/projects/:project/suggestions — filter on status, source, or reporter_external_id, and page backwards with ?before_id=. Pages are capped at 200.

Reporter identity is minimized. Flicker never mints an account for your user — reporter_external_id is your own opaque id. It and reporter_email are returned only on a list you already filtered by reporter_external_id; the feed, the unfiltered list, and single reads leave them out entirely, and no event payload ever carries them.

When your user deletes their account, purge what they sent: DELETE /api/v1/projects/:project/suggestions/by-reporter with source and reporter_external_id. It erases every matching suggestion and its events whatever the status, returns how many it deleted, and is safe to call repeatedly. A reporter can also withdraw a single report they still own with DELETE /api/v1/suggestions/:id plus a matching reporter_external_id — that one only works while the suggestion is still open.

The public feature-request board

Publish a request with PATCH /api/v1/suggestions/:id and action=publish. Only kind: "feature_request" can be published: bug reports are private forever, and attempts return 422 publish_requires_feature_request. A published request must have a summary; that is the only text the board shows, so the reporter's raw body is never exposed. A missing summary returns 422 publish_requires_summary. Use action=unpublish as the takedown lever; it always works.

publish, vote, and read the board
$ curl -s -X PATCH https://flickercloud.com/api/v1/suggestions/7 \
  -H "Authorization: Bearer $FLICKER_TOKEN" -d action=publish
$ curl -s -X POST https://flickercloud.com/api/v1/suggestions/7/vote \
  -H "Authorization: Bearer $FLICKER_TOKEN" \
  -d source=ios -d reporter_external_id=user-99
$ curl -s "https://flickercloud.com/api/v1/projects/my-app/board?source=ios&reporter_external_id=user-99" \
  -H "Authorization: Bearer $FLICKER_TOKEN"

The public status vocabulary deliberately differs from internal state:

Internal state Public copy
open Under review
accepted Planned, or In progress / Shipped from the linked ticket
resolved Shipped
rejected Declined, with its rejection reason
!
A rejection reason on a published request is public writing — it is shown to everyone who can see the board.

Vote with POST /api/v1/suggestions/:id/vote, and withdraw with DELETE /api/v1/suggestions/:id/vote. Both take source and reporter_external_id. There is one vote per reporter per request; repeats are idempotent. A vote is an implicit subscription, and unvote unsubscribes.

Merge a duplicate with PATCH /api/v1/suggestions/:id, action=merge, and canonical_id. Votes transfer to the canonical request; someone who voted on both sides counts once. The response reports votes_transferred and votes_deduped. Merges are single-level.

GET /api/v1/projects/:project/board returns published, unmerged feature requests ranked by raw vote count with a recency tiebreak. Every row has vote_count and has_voted; page with ?limit= and ?offset=. The response contains no body and no reporter fields.

GET /api/v1/suggestions/:id/notification-cohort requires an org-wide key and resolves who to notify when called, never storing the cohort. It returns source + reporter_external_id identities for the reporter, reporters of merged duplicates, every voter, and reporters of suggestions sharing the linked ticket.

merge and resolve the notification cohort
$ curl -s -X PATCH https://flickercloud.com/api/v1/suggestions/8 \
  -H "Authorization: Bearer $FLICKER_TOKEN" \
  -d action=merge -d canonical_id=7
$ curl -s https://flickercloud.com/api/v1/suggestions/7/notification-cohort \
  -H "Authorization: Bearer $FLICKER_TOKEN"

If the project has a published Showcase under an enabled organization profile, the same board is readable at flickercloud.com/showcase/{org}/{project}/board. That hosted page is read-only: summaries, statuses, and counts only, with no personal data and no way to vote from it.

i
Tickets have a full web UI as well: open a project and switch to its Tickets board to plan, transition, and read documents without leaving the dashboard.