Ephemeral in-memory annotation store.
A trivial GenServer owns a public, named ETS table so the MCP and HTTP
handlers (running in web-server processes) can read and write directly
without a GenServer round-trip. The owner holds no logic, so a handler crash
can never take the table down with it. Annotations live only in memory and are
wiped when the host app restarts — by design for a dev-only feedback tool.
Every stored value is a Annotai.Annotation struct.
Summary
Functions
Append a human reply to an annotation's thread.
Append a message to an annotation's reply thread.
All annotations, oldest first.
Returns a specification to start this module under a supervisor.
Remove all annotations.
Delete one annotation.
Fetch one annotation by id, or nil.
Fetch a single attached image (with its base64 data) by annotation + image id, or nil.
Epoch-ms of the last agent request, or nil if none yet.
Annotations still awaiting the agent (:pending or :acknowledged), oldest first.
Store an annotation, filling in id and inserted_at when absent.
Bulk-insert annotations verbatim, used to hydrate the table from persistence.
Record that an agent just made an MCP request.
Apply a guarded lifecycle transition.
Merge changes (a map of struct fields) into an annotation.
Functions
@spec add_human_reply(String.t(), String.t()) :: {:ok, Annotai.Annotation.t()} | {:error, :not_found}
Append a human reply to an annotation's thread.
On an open (:pending/:acknowledged) annotation this just appends. On a
terminal (:resolved/:dismissed) one it also reopens the thread —
status back to :pending, resolved_at/resolved_by cleared — so the agent
picks the work up again (a reply the agent never sees would be a dead end).
The prior resolution summary stays in the thread as context for the next pass.
This is the browser-facing counterpart to the agent's add_thread_message/3.
@spec add_thread_message(String.t(), String.t(), String.t()) :: {:ok, Annotai.Annotation.t()} | {:error, :not_found}
Append a message to an annotation's reply thread.
Returns {:ok, annotation} or {:error, :not_found}.
@spec all() :: [Annotai.Annotation.t()]
All annotations, oldest first.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec clear() :: :ok
Remove all annotations.
@spec delete(String.t()) :: :ok
Delete one annotation.
@spec get(String.t()) :: Annotai.Annotation.t() | nil
Fetch one annotation by id, or nil.
Fetch a single attached image (with its base64 data) by annotation + image id, or nil.
@spec last_agent() :: integer() | nil
Epoch-ms of the last agent request, or nil if none yet.
@spec pending() :: [Annotai.Annotation.t()]
Annotations still awaiting the agent (:pending or :acknowledged), oldest first.
@spec put(Annotai.Annotation.t()) :: Annotai.Annotation.t()
Store an annotation, filling in id and inserted_at when absent.
@spec restore([Annotai.Annotation.t()]) :: :ok
Bulk-insert annotations verbatim, used to hydrate the table from persistence.
Unlike put/1, ids and timestamps are preserved as-is (never regenerated), and
no change notification is emitted — hydration is loading what was already
persisted, not a new mutation to snapshot back.
@spec touch_agent() :: :ok
Record that an agent just made an MCP request.
@spec transition(String.t(), [Annotai.Annotation.status()], map()) :: {:ok, Annotai.Annotation.t()} | {:error, :not_found} | {:error, {:invalid_transition, Annotai.Annotation.status(), [Annotai.Annotation.status()]}}
Apply a guarded lifecycle transition.
The annotation's current status must be one of allowed_from, otherwise it is
left untouched — this is what makes acknowledge/resolve/dismiss idempotent.
resolved_at is stamped automatically when the new status is terminal
(:resolved/:dismissed).
@spec update(String.t(), map()) :: {:ok, Annotai.Annotation.t()} | {:error, :not_found}
Merge changes (a map of struct fields) into an annotation.
Returns {:ok, annotation} or {:error, :not_found}. Used for plain edits
(e.g. the developer editing a comment); lifecycle changes go through
transition/3.