cd /news/ai-agents/building-shoots-a-hybrid-event-drive… · home topics ai-agents article
[ARTICLE · art-116398] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

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

A developer built Shoots, a hybrid event-driven agent system for photography, as an entry for the All Things Agentic Hackathon. The system uses constrained model calls within an inspectable workflow, where agents communicate via Pub/Sub and domain rules are kept separate from I/O to ensure reproducibility. The developer deliberately avoided chains of agents passing prose, instead using structured schemas and measured facts to veto conflicting visual claims.

read7 min views1 publishedAug 31, 2026

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

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.

Shoots has two coordination layers, because model orchestration and background

work have different failure modes.

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.

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.

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.

At-least-once delivery is normal with Pub/Sub. I treated it as a design condition,

not an error case.

ANALYSING

lease, so a duplicate delivery does not buy another panel run. A genuinely abandoned lease can later be reclaimed.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.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.

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:

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.

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 down it.

The Deconstruction is a draft built from already-stored Evidence. It never posts itself.

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.

── more in #ai-agents 4 stories · sorted by recency
── more on @shoots 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-shoots-a-hy…] indexed:0 read:7min 2026-08-31 ·