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