I created this article for the purposes of entering the All Things Agentic Hackathon.
Shoots is an Android and web photography Companion. A photographer takes ordinary
Shots. Shoots reviews them in the background, preserves the Evidence behind its
reading, and can offer one optional Experiment when the record supports it.
This is the technical version of the project. It is about how a file becomes a
durable learning record, how the agents communicate, and where I deliberately
refused to let a model make the decision.
The design in one sentence
The model panel reads a single Shot. The system around it does the work: it
creates a durable Run, moves tiny events through independently retryable stages,
re-reads state at every boundary, records every outcome, and only settles a Shoot
after every member Run is accounted for.
That distinction matters. I did not want a chain of agents passing prose to one
another until it sounded convincing. I wanted constrained model calls inside a
workflow whose state, retries, and outputs could be inspected later.
Repository structure
The codebase is deliberately split by responsibility rather than by screen or
agent name:
android/
phone/ # approved Camera media
work/ # background upload and retry
data/ # cache, API, identity
ui/ # Android screens
backend/app/
api/ # FastAPI ingress and push endpoints
domain/ # pure rules and state transitions
imaging/ # EXIF, pixels, visual artifacts
agents/ # ADK agents and prompts
services/ # workflow orchestration
infra/ # storage, Pub/Sub, Drive, secrets
frontend/src/
stores/ # API and SSE state
pages/ # web audit desk
components/ # receipts and visual Evidence
infra/ # Google Cloud deployment and Scheduler
The important boundary is domain/. It has no I/O. It owns the rules that must be
reproducible: grid-cell conversion, taxonomy validation, panel consensus, Criteria
checks, Technique Map projections, and Run state transitions. services/ can call
models and storage. domain/ cannot.
Two kinds of orchestration
Shoots has two coordination layers, because model orchestration and background
work have different failure modes.
1. An in-process ADK panel reads one Shot
The Analyst is an ADK SequentialAgent. Its first step is a ParallelAgent with
three readers. Its second step is a Synthesizer.
gridded Shot + camera facts ──> Technician
gridded Shot ──> Composer
clean Shot ──> Storyteller
│
▼
structured readings + measured facts
│
▼
Synthesizer, with no image input
The readers do not all see the same prompt and image. The Technician gets EXIF and
the gridded Shot. The Composer gets the gridded Shot for composition and light. The
Storyteller gets the clean Shot. The Synthesizer gets their structured readings and
the measurements, but no image.
Each reader returns a Pydantic schema, not an unbounded paragraph. Before a model
claim reaches storage, code drops unknown Technique IDs, invalid cell references,
and unsupported geometry. domain/panel.py then requires agreement between
readers, or a higher-confidence read from the Technique's accountable reader.
Measured facts can veto a conflicting visual claim.
For example, a model can say where it sees a motion technique, but phase-correlation
code can rule out a locked-off camera. A model can locate a region with grid cells,
but it never emits pixels or a hand-drawn overlay.
ADK coordinates the small panel inside the Analyst. Pub/Sub coordinates the durable work outside it.
2. Pub/Sub coordinates independently retryable stages
Agent stages do not call the next remote stage directly. They publish a small,
ID-only message, then the next handler re-reads the Shot and its current state.
source accepted
-> create Shot + durable Run
-> media.new
-> Ingest
-> media.ingested
-> Analyst
-> media.analyzed
-> Cartographer
-> Judge
-> media.judged
-> Scribe
The actual message is intentionally boring:
await ctx.bus.publish(TOPICS["media.analyzed"], {"shot_id": shot.id})
That one choice avoids a lot of accidental complexity. Pub/Sub never carries a
stale Analysis object or an agent's prose. A handler loads the authoritative
records it needs, writes one idempotent outcome, and can safely see the same event
again.
media.analyzed fans out to Cartographer and Judge. They get separate push
subscriptions, retry policies, and dead-letter paths even though they start from
the same event. Judge always emits media.judged, including when there is no
Experiment Verdict, so Scribe can still write the reviewed output.
Local development uses an InProcessBus. Production uses Pub/Sub push requests to
/pubsub/<stage> on Cloud Run. The handlers are registered the same way in both
modes. Transport changes, but the stage code does not.
The state machine is more important than event order
Every accepted Shot gets a durable Run before the first event is published. A Run
has a separate outcome for Ingest, Analyst, Cartographer, Judge, Scout, and Scribe.
The outcomes can be complete, skipped, retrying, or terminal.
ActivityEvent records explain what happened. Run state decides whether the
work is finished. This prevents a misleading result such as "review complete"
because Scribe happened to finish while Cartographer was still retrying.
Once every member Run of a natural camera period has settled, Shoots creates a
Shoot Record. Only then does the Shoot-level Scout choose a typed route: explain
supported work, ask one consequential question, offer an evidence-backed
Experiment, or stay silent. The model writes within a route that code already
selected. It does not get to invent a new task for the photographer.
many Shot Runs settle
│
▼
Shoot barrier
│
▼
Shoot synthesis -> typed Scout decision -> Shoot Record
This is also why a Shoot is not just a time window. It is a revisioned record with
exact member Shots. A later Camera Shot can create a newer revision without
rewriting the earlier record.
Making retries safe, including model cost
At-least-once delivery is normal with Pub/Sub. I treated it as a design condition,
not an error case.
- A stable source reference makes a redelivered import a no-op.
- Ingest and Analyst claim the Shot before doing expensive work. The Analyst uses a
timed
ANALYSINGlease, so a duplicate delivery does not buy another panel run. A genuinely abandoned lease can later be reclaimed. - The process-local
StageGatelimits expensive Analyst panels on one Cloud Run instance. Pub/Sub owns the durable backlog, so the gate is not a fake global queue. - Failed deliveries go to a dead-letter path, but that is not considered finished. Cloud Scheduler scans durable retrying Runs and republishes the original stage event. It does not mutate the Run to "complete" behind the normal handler.
The result is a repair path I can audit. A replay records which stage was retried
and when. It does not pretend the failure never happened.
Where model authority ends
I use Gemini 3.7 Flash for bounded visual interpretation and writing. Deterministic
code owns evidence rules, state changes, and anything that sounds like a fact.
The boundary is easiest to see in three places:
- Panel consensus. Gemini can propose Technique Evidence, but code validates IDs and grid cells, applies quorum rules, and lets hard evidence veto a claim.
- Visual Evidence. A model can bound a possible area using grid cells. OpenCV and NumPy then render a measurable artifact inside that boundary. For edge evidence, the renderer uses Canny edges. For detail, it uses a Laplacian map. If the renderer cannot support the location, the interface shows no persuasive overlay.
- Crops. The Composer may propose a crop. Pillow renders it, then a separate Gemini rater compares the original and crop for at most two rounds. Shoots only keeps the crop when that comparison explicitly improves. It remains a tested suggestion, not an objective quality score.
The cyan marks are measured contrast edges in the model-located area. They support a narrow claim about visible edge structure, not a verdict on the composition.
This is why Shoots does not store an overall aesthetic score. It stores Evidence,
Findings, a labelled visual reading, and the exact model and prompt version that
produced that reading. A Reproduce Verdict is reserved for Criteria that were
declared before the photographer tried again. Explore and Compare do not get a
pass/fail label.
Durable artifacts, not a chat history
Firestore holds the Photographer record, Shots, Analyses, Runs, ActivityEvents,
Technique Map projections, Experiments, and Shoot Records. Cloud Storage holds
original files and display assets. Google Drive is an optional import and reviewed
output adapter.
Scribe can prepare a Deconstruction draft from stored Evidence. It cannot treat the
current image as permission to invent a new claim. The writer gets supported
Evidence IDs; domain code validates those references and the photographer reviews
the result before sharing or downloading it.
The Deconstruction is a draft built from already-stored Evidence. It never posts itself.
What I measured
I ran a recorded production Drive-import workflow with 75 test files in three
batches. All 75 completed. Shoots wrote 75 reviewed copies to Google Drive and
produced three settled Shoot Records. Five failed Shots recovered through six
automatic repair replays.
The median backend Run took 48.72 seconds. The complete session took 40 minutes and
7 seconds from the first import request to the final Shoot Record. Because the
batches overlapped, multiplying the median by 75 would be misleading.
I also ran a separate five-Shot metered sample with real Gemini calls. The model-only
estimate was about $0.039 per Shot, or $2.95 projected to 75. That is not the
historical bill. It excludes cloud infrastructure, Drive transfer, discounts,
credits, optional Experiments, and visual-story generation.
The 75 files included repeats and deterministic variations of real hobbyist Shots.
That proves the workflow, repair path, and delivery. It does not prove that Shoots
makes someone a better photographer. That needs people using it over time and
telling me whether the Experiment was useful.
The source is open at github.com/fikri2992/shoots. The project entry is Shoots on Devpost.



Top comments (1)
Hello Fikuri, I am Kane Lim from Hong Kong. I have over 10 years of development experience. I am writing this because your post was interesting.
Shoots is a very strong example of treating agentic AI as a distributed systems problem rather than simply chaining LLM calls. I particularly like the separation between domain rules, model interpretation, and durable orchestration. Making Pub/Sub messages ID only is an excellent decision because it gives every consumer a consistent source of truth and makes replay behavior much safer.
I would take this architecture one step further with event sourcing for Run state and deterministic workflow reconciliation. Instead of relying mainly on the current Run status, each transition could produce an immutable event with schema version, causation ID, correlation ID, model version, prompt hash, and input artifact digest. A projection layer could then rebuild Run state whenever required. This would make debugging duplicate delivery, partial failure, stale leases, and model regressions substantially easier.
For the agent layer, I would also introduce contract based evaluation using stored Evidence as fixtures. Every prompt or model version change could automatically execute the same corpus and compare structured outputs, consensus behavior, veto conditions, and evidence localization. That creates a regression harness for the entire agentic pipeline rather than evaluating generated prose.
The combination of deterministic validators, measurable visual artifacts, idempotent handlers, replayable events, and bounded model authority is exactly the direction I would take for a production grade system. I would be very interested in discussing how you plan to evolve the evaluation layer as the Technique taxonomy grows.
I would like to get to know you better and discuss about your post. Would you please contact me? t_g_@kanelim1997