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-
factory_audit— written by the Factory when its audit finds a ticket not ready to build: the findings and the questions a person must answer. A later audit does not read it back, so answering means updating the ticket or its contract
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
Open your organization Overview at /o/:org
to see suggestions across projects, including new reports, rule holds, stalled triage, and webhook delivery failures. Use All projects for the project directory.
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.
Organization managers can choose off, enrich, or
triage
in Project settings → Suggestions. That page also
shows which apps have a suggestions key, links to triage rules, and offers retriage of
open reports. A wired app needs a redeploy to receive its injected key.
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. It may also reject
a report it is certain nobody will work — spam, advertising, a test submission — which
emits the rejected
event carrying a rejection_reason
written for that reporter. Rejection clears a higher bar than either of the other two, and
a rejection without a reason is never sent: it is left for a human instead. Below its
confidence floors triage attaches nothing, creates nothing, and rejects nothing.
$ 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, and optionally an outcome_code
that the rejected
event carries).
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 (or have them pushed — see suggestion webhooks). Every event carries its suggestion's
submission_id
and metadata
so you can match it to your own rows without another request; both are null
if the suggestion no longer exists.
The cursor is safe to keep as a single number: a project's event ids are assigned in
commit order, so once you have seen id N, no lower id in that project
can appear later. That holds across a project move too: events moved into a
project are re-issued under new ids, so they arrive after your cursor, and a consumer that
already took them (by webhook or poll) sees them again: dedupe on suggestion + event type, or tolerate replays. Store
next_after_id
(or the highest id you processed) and pass it back; you do not need to re-read an
overlap window. Event ids are unique, so if you also take webhooks, dedupe on
id
across both paths. 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.
Suggestion triage rules
A triage rule
tells AI triage how to judge one class of a project's suggestions. Rules are written in
the product, under the project's Suggestions → Triage rules; any organization member can read them and only
managers can create, edit or delete one. They apply when the project's AI mode is triage.
A rule matches
on fields the suggestion already carries: source, kind
and metadata.type. Each is an exact match, the ones you fill in must all match,
and a rule with none filled in is the project's default. When several match, the one with the most fields filled in wins,
ties go to the older rule, and the default applies only when nothing more specific does. With no matching rule, triage runs exactly as it does without rules.
kind
is the suggestion's stored kind when triage runs, which triage itself may have reclassified — match on
source
or metadata.type
when you need a stable class.
A rule carries:
- Instructions — up to 2,000 characters of plain language. Triage reads them as the owner's policy, and they are copied into every ticket the rule promotes, so whoever works the ticket works under the same rule.
- A decision mode — Auto (the confidence floors decide), Never promote (triage may reject or leave the report for a human, but never attaches or opens a ticket), or Hold for review (a person decides every one; triage still summarises and lists duplicates).
-
Optional reject outcomes
— up to 8 named reasons, written one per line as
code: message. The code is lowercase snake_case; the message (40 to 400 characters) is what the reporter reads. When a rule has outcomes, triage may reject only under one of them: the reporter is sent that outcome's message instead of the model's, and therejectedevent carries the code asoutcome_codeso your app can branch on why — for exampleneeds_measured_weightversusalready_covered, where the fix is on your side and no ticket is opened. A reject that names no listed code is left for a human.
A rule can make triage more careful or reword a rejection. It cannot lower a confidence floor: everything it allows still clears the same attach, create and reject bars. The instructions go to the model in a separate, owner-only part of the prompt. The suggestion's body and metadata are user-submitted, and the model is told never to follow instructions found in them.
Triage sees the suggestion's metadata, so the context a rule needs comes from your app: send it in
metadata
(say, the conversions you already have for this ingredient) and say in the instructions what to look for there.
Every triage decision records the rule's id and version. Saving a rule increments its version. Old revisions' text is not kept.
A ticket opened under a rule also carries the suggestion's source,
submission_id
and metadata.
Suggestion webhooks
Instead of polling, have Flicker push each feed event to your server. Configure one
endpoint per project with PUT /api/v1/projects/:project/suggestion-webhook
and a url. This needs an org-wide API key: a suggestions-scoped key gets 403, because pointing the URL somewhere exports the whole feed. The
response carries the signing secret
exactly once, on create and again when you send rotate_secret: true; Flicker stores it encrypted and never returns it otherwise.
Organization managers can do the same from the project's Settings page (Suggestions, Webhook), which also shows the recent deliveries, with a page per delivery and a Redeliver action.
$ curl -s -X PUT https://flickercloud.com/api/v1/projects/my-app/suggestion-webhook \ -H "Authorization: Bearer $FLICKER_TOKEN" \ -d url=https://api.example.com/flicker/webhook $ curl -s -X POST https://flickercloud.com/api/v1/projects/my-app/suggestion-webhook/ping \ -H "Authorization: Bearer $FLICKER_TOKEN" $ curl -s https://flickercloud.com/api/v1/projects/my-app/suggestion-webhook/deliveries \ -H "Authorization: Bearer $FLICKER_TOKEN"
Each request body is exactly one feed event
— the same JSON object as one element of the feed's events
array, so one handler serves both. Every request is signed:
-
Flicker-Webhook-Id,Flicker-Event-Id,Flicker-Event-Type Flicker-Timestamp— unix seconds of this attempt-
Flicker-Signature: v1=<hex>— lowercase hex HMAC-SHA256 of"<timestamp>.<raw body>"with your secret. There may be several comma-separatedv1=values; accept if any matches.
Verify over the raw
body with a constant-time compare, and reject a timestamp more than 300 seconds
from now. Answer with any 2xx within 10 seconds and do the work asynchronously.
Anything else — a non-2xx, a timeout — is retried with backoff, 12 attempts over
about 32 hours, and then marked gave_up
in the delivery log. Redirects are not followed, and the URL must be https
and resolve only to public addresses, checked when you save it and again on every send.
Delivery is at-least-once and unordered: dedupe on the event id. Event ids increase in insert order, so applying an event only when its
id is greater than the last one you applied for that suggestion is safe against a
late retry. A test ping has type: "ping"
and no id
— return 2xx and ignore it — and so should any type you do not handle. Keep a slow
poll of the feed as a backstop, and do not IP-allowlist Flicker: deliveries can come
from more than one address.
POST /api/v1/suggestion-webhook-deliveries/:id/redeliver
re-sends a logged delivery, freshly signed, with the same event id.
Payload keys are a contract.
Each event's payload
carries these keys, in the feed and in webhook bodies alike. They are covered by a test,
and renaming or removing one is a breaking change. automation
is present only when triage made the decision automatically; it holds actor, decision, model,
reason
and confidence, each optional.
| Type | Payload keys |
|---|---|
created |
source, kind, submission_id |
accepted, resolved |
ticket_id, reporter_external_id, source, kind, and optionally
automation
|
rejected |
rejection_reason, reporter_external_id, source, kind, and optionally
automation
and outcome_code
(a triage rule's named reason)
|
merged |
canonical_id
(not merged_into_id), votes_transferred, votes_deduped, reporter_external_id,
source
|
merge_received |
merged_suggestion_id, votes_transferred,
votes_deduped
|
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.