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
  • 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).

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

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.

!
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.

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.

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, 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.

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 (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 the rejected event carries the code as outcome_code so your app can branch on why — for example needs_measured_weight versus already_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.

configure, test, and read the delivery log
$ 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-separated v1= 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.

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.