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.
$ 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, risksplan_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 designimplementation_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).
$ 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.
$ 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.
$ 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.
$ 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".
What this is not
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.
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.
$ 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).
$ 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.
$ 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.
$ 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 |
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.
$ 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.