Technology Aug 31, 2026 · 8 min read

Building Shoots: a hybrid event-driven agent system for photography

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

DE
DEV Community
by fikuri
Building Shoots: a hybrid event-driven agent system for photography

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.

The hybrid event-driven Shoots workflow on Google Cloud

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 ANALYSING lease, so a duplicate delivery does not buy another panel run. A genuinely abandoned lease can later be reclaimed.
  • The process-local StageGate limits 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:

  1. Panel consensus. Gemini can propose Technique Evidence, but code validates IDs and grid cells, applies quorum rules, and lets hard evidence veto a claim.
  2. 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.
  3. 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.

A rice-field Shot with measured contrast edges in the named pattern area

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.

A visual story generated from a selected Shot and its stored Evidence

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.

DE
Source

This article was originally published by DEV Community and written by fikuri.

Read original article on DEV Community
Back to Discover

Reading List