An open harness for proactive ambient agents: things that watch and listen, and speak up only when it's actually useful.
It copies how attention works in the brain. Cheap peripheral awareness scans constantly; when something is salient, it snaps focus onto it to reason and act. Named after the saccade: the eye's flick from periphery to focus.
Sensors ──▶ Glance ──▶ Percept ──▶ salient? ──▶ Focus ──▶ Decision ──▶ Act
(inputs) (cheap (what it (model's (smart (speak /
model,1Hz) saw) own call) model) do)
│ │
└───────────────▶ Memory ◀────────────┘
working / episodic / semantic
Two model judgments, no rule engine: Glance decides worth a closer look?,
Focus decides speak, and what?. Preferences and recent history are context
fed to the model, not if branches. Everything else swaps behind a Protocol:
- Models live only in
backends/(Gemini, OpenAI, Anthropic, Ollama, stub). The intended setup is a cheap local model for the always-on Glance tier (private, free, no rate limits) and a bigger API model when something escalates. Run any mix while you build toward that. - Inputs are
Sensors (camera, screen, mic, RTSP, replay); outputs areSpeakers (print, TTS, webhook, hardware). One file each, drop-in. - Cost is the cascade, not tricks: the cheap tier gates the expensive one, Glance downscales its input, and cadence adapts. No heuristic pre-filters.
Extending this repo, as a human or an AI coding agent, comes down to one file per new piece. The rules below keep the design intact.
Hard rules. Breaking these breaks the design.
- No hand-coded decision rules. The only judgments are the two model calls
(
glance.py,focus.py). Salience, urgency, tone: context fed to the model, neverif x and y: speak. - Vendor SDKs only in
backends/andspeakers/gemini_tts.py. If you find yourself importinggoogle.genaioropenaianywhere else, that's the wrong layer. - Don't break the Protocols in
sensors/base.py,backends/base.py,speakers/base.py. Every concrete class is interchangeable; that's the point. - Structured output goes through the schemas in
schema.py(PERCEPT_SCHEMA,DECISION_SCHEMA). Don't parse free text and don't add a fourth schema unless you're adding a fourth role.
Where to add things. One file each; nothing else changes.
- New camera/mic/screen →
sensors/yours.pyimplementingSensor.stream(). - New model provider →
backends/yours.pyimplementingBackend.complete(). Translateschemato the provider's native structured-output mechanism. - New voice output (a speaker, a TV, a phone) →
speakers/yours.pyimplementingSpeaker.say(). - Register the new class in
__main__.py's small dispatch.
Before claiming done.
uv run python -m pytest -q: all green.uv run python -m saccadewith no env: the scripted stub run still works end-to-end.- If you touched a real path (camera, model, speaker), actually run it. Tests verify code; running verifies the feature.
Config + secrets. saccade/config.py auto-loads .env at import time
(stdlib only, no python-dotenv). Real env wins over .env. Add new vars as
dataclass fields with os.environ.get(...) defaults; don't read env scattered
across modules.
Avoid. A fallback that "tries the next backend on error" (a silent provider switch is a debugging trap). A "smart" cache that compares frames (image diff = hand-coded salience, see hard rule #1). Refactoring what wasn't asked. Docstring blocks: one short line max.
Install.
git clone https://github.com/jayzuccarelli/saccade && cd saccade
uv venv
source .venv/bin/activate
uv pip install -e '.[camera,screen,audio]'The extras are what let saccade see and hear: uv pip install -e . on its own
installs no camera, screen, or mic support. No uv? python3 -m venv .venv && source .venv/bin/activate && pip install -e '.[camera,screen,audio]' does the same.
Pick your devices.
uv run python -m saccade setupProbes this machine and asks three questions: what to watch or hear (your
built-in camera, an external one, a screen, a mic, or camera + mic together),
which model thinks, and whether it answers as text or out loud. Writes a .env
you can edit by hand afterwards. Then start it:
uv run python -m saccadeOr skip setup entirely. No key, no camera: runs a scripted scene on the stdlib alone, so you can see the loop before wiring anything up:
uv run python -m saccadeYou'll see Glance/Percept/Focus output in the terminal. That's the whole loop, just with a stub model and a scripted sensor.
The sections below swap in real models; pair them with a real sensor
(SACCADE_SENSOR=webcam / screen / reolink, further down) or sanity-check
one image with uv run python -m saccade snapshot pic.jpg. With no sensor set, the
scripted stub feeds a real model no images (saccade warns if you try).
Go local, private, free with Ollama (recommended for the always-on Glance tier: no API bill, no rate limits, frames never leave your machine):
curl -fsSL https://ollama.com/install.sh | sh
ollama pull gemma3:4b
ollama pull gemma3:12b
SACCADE_GLANCE_BACKEND=ollama SACCADE_FOCUS_BACKEND=ollama uv run python -m saccadegemma3:4b (~3GB, multimodal) is the Glance default; gemma3:12b is smarter and
the Focus default.
Any Ollama vision model works; swap with SACCADE_GLANCE_MODEL=qwen2.5vl:7b etc.
Or use a hosted model. Pick a provider, add a key, mix and match per tier:
# Gemini (default: Glance=3.1 Flash-Lite, Focus=3.5 Flash)
uv pip install google-genai
SACCADE_GLANCE_BACKEND=gemini SACCADE_FOCUS_BACKEND=gemini \
GEMINI_API_KEY=your_key uv run python -m saccade
# Or OpenAI, or Claude: same harness (validated path is Gemini + Ollama;
# OpenAI/Anthropic backends follow each provider's spec but aren't live-tested):
SACCADE_GLANCE_BACKEND=openai SACCADE_FOCUS_BACKEND=anthropic \
OPENAI_API_KEY=... ANTHROPIC_API_KEY=... uv run python -m saccadeStructured output is enforced provider-agnostically: each role declares a JSON
Schema and each backend translates it natively (Ollama format, Gemini
response_json_schema, OpenAI response_format, Claude forced tool-use). Cheap
and smart tiers are independent: Glance on Ollama + Focus on Gemini is a
common combo (private always-on, paid SOTA only when something escalates).
Point it at one image (fastest way to sanity-check a key):
uv run python -m saccade snapshot photo.jpgSee what's plugged into this machine. Enumerates cameras, screens, mics,
and audio outputs; the .env lines: under each section paste verbatim into .env:
uv run python -m saccade devicesPoint it at your laptop webcam (Mac / Linux / Windows; cv2 picks the OS backend):
uv pip install -e '.[camera]'
SACCADE_SENSOR=webcam uv run python -m saccadeSet SACCADE_WEBCAM_INDEX=1 if you have more than one cam.
macOS: grant Camera access to your terminal app (System Settings > Privacy & Security > Camera); approve the prompt on first run, then rerun.
Point it at your screen (watch what you're doing, useful for coding-agent side-cars, meeting notes, "did I actually close that tab"):
uv pip install -e '.[screen]'
SACCADE_SENSOR=screen SACCADE_SCREEN_INDEX=1 uv run python -m saccadeIndex 1 is the primary monitor; uv run python -m saccade devices lists them all.
macOS: grant Screen Recording to your terminal app (System Settings > Privacy & Security > Screen Recording) and restart the terminal; without it, mss silently captures wallpaper-only frames and the agent watches nothing.
Let it hear (local microphone). Each tick records a short clip and sends it to the model, so the agent reacts to sound, not just sight:
uv pip install -e '.[audio]'
SACCADE_SENSOR=mic SACCADE_GLANCE_BACKEND=gemini SACCADE_FOCUS_BACKEND=gemini \
GEMINI_API_KEY=... uv run python -m saccadeSACCADE_MIC_INDEX=1 picks a device; -1 (default) uses the system mic. No
hardware? Test hearing on a file: uv run python -m saccade snapshot clip.wav.
Hearing needs an audio-capable backend: gemini today. Anthropic and Ollama
(gemma) are vision-only; OpenAI needs a dedicated audio model. Linux also needs
PortAudio (sudo apt install libportaudio2); it's bundled in the Mac/Windows
wheels.
See and hear at once (webcam + mic fused). Each glance carries both a camera frame and an audio clip of the same instant, so the agent reasons over sight and sound together:
uv pip install -e '.[camera,audio]'
SACCADE_SENSOR=av SACCADE_GLANCE_BACKEND=gemini SACCADE_FOCUS_BACKEND=gemini \
GEMINI_API_KEY=... uv run python -m saccadeHear without uploading the room. Raw audio only reaches Gemini, the one backend that accepts it, so a microphone used to mean streaming your kitchen to a vendor. Transcribe locally instead and the audio never leaves; what reaches the model is a line of text, which every backend can read, including Ollama:
uv pip install -e '.[audio,stt]'
SACCADE_SENSOR=mic SACCADE_STT=whisper \
SACCADE_GLANCE_BACKEND=ollama SACCADE_FOCUS_BACKEND=ollama uv run python -m saccadeUses faster-whisper (MIT), which
bundles FFmpeg through PyAV so there's no system install on any platform.
SACCADE_STT_MODEL picks the Whisper size (default base). When transcription
is on, the audio is deliberately not attached to the frame: sending both would
hand the recording to the vendor anyway and give up the reason to transcribe
locally at all.
Watch and listen to different things at once. Comma-separate the sensors and
each runs at its own pace, frames interleaved. This is the one to reach for when
the inputs are independent (watch the screen, hear the room); av is the one for
when the picture and the sound have to describe the same instant:
uv pip install -e '.[screen,audio]'
SACCADE_SENSOR=screen,mic SACCADE_GLANCE_BACKEND=gemini \
GEMINI_API_KEY=... uv run python -m saccadeIf any one of them fails, the run stops rather than quietly continuing on the inputs that still work.
Point it at an RTSP camera (Reolink or anything that speaks RTSP). Give the
parts and saccade assembles + URL-encodes the URL: a password with @ : / #
won't break it:
uv pip install -e '.[camera]'
SACCADE_SENSOR=reolink \
SACCADE_RTSP_USER=admin SACCADE_RTSP_PASSWORD='your-pass' \
SACCADE_RTSP_HOST=192.168.1.100:554 SACCADE_RTSP_PATH=/h264Preview_01_sub \
uv run python -m saccadeOr pass a full SACCADE_RTSP_URL='rtsp://...' yourself.
Stop typing env vars every run. Copy .env.example → .env (gitignored)
and fill it in. saccade auto-loads it, so uv run python -m saccade just works.
| Path | What |
|---|---|
schema.py |
the contracts: Frame, Window, Percept, Decision |
sensors/ |
input streams: stub, webcam, screen, mic, av (webcam+mic), reolink, replay (Protocol in base.py) |
devices.py |
uv run python -m saccade devices: enumerate cameras/screens/mics/audio-outs |
backends/ |
swappable models: stub, ollama (local, stdlib), gemini, openai, anthropic (the only files that touch a model SDK) |
speakers/ |
swappable output: print (default), piper (local TTS, no key), gemini_tts (hosted TTS), home_assistant (example of a remote-output speaker) |
glance.py |
cheap peripheral perceiver → Percept |
focus.py |
on-demand deep reasoner → Decision |
memory.py |
working / episodic / semantic |
loop.py |
the orchestrator |
When Focus decides to act, the message goes to a Speaker. "Speaker" is the
generic name for any output: print, audio, an HTTP webhook, a phone push, a
hardware actuator. Default is print (no audio, no key, works out of the box).
To actually talk, use Piper: local, offline, no API key, same on Linux, macOS and Windows. Install it yourself: it's GPL and saccade is MIT, so saccade runs it as a subprocess rather than depending on it:
uv pip install piper-tts
uv run python -m piper.download_voices en_US-lessac-medium
SACCADE_SPEAKER=piper uv run python -m saccadeUse uv run (or activate the venv first). A bare python isn't on PATH at all
on macOS, and a bare pip installs somewhere the running saccade can't import
from, which gets you piper installed and No module named piper in the same
terminal.
SACCADE_PIPER_VOICE picks the voice (uv run python -m piper.download_voices with no
argument lists them). Voices download to the working directory; if you run
saccade from elsewhere, point SACCADE_PIPER_DATA_DIR at them.
Gemini TTS is the hosted upgrade: better voices, at the cost of a key and a network round trip per utterance:
SACCADE_SPEAKER=gemini_tts GEMINI_API_KEY=... uv run python -m saccadeThe box that watches often has no audio out, so by default the speaker writes
the clip (SACCADE_TTS_DIR, default utterances/) and prints where. Point
SACCADE_PLAY_CMD at anything that takes a file path to actually play it:
aplay, afplay, or a wrapper that pushes audio elsewhere. SACCADE_TTS_VOICE
picks the voice (default Kore).
To play out a specific speaker rather than the OS default, install the audio
extra and set SACCADE_AUDIO_OUT_INDEX to a device index from saccade devices,
the output twin of picking a mic. It overrides SACCADE_PLAY_CMD.
Adding a new output (a phone notification, a webhook, an MQTT message, a
hardware actuator, a smart-home media player) is one Speaker class in
speakers/. Nothing else changes. See speakers/home_assistant.py for a
worked example of a remote-output speaker.
Glance runs constantly, so cost = cadence × price. Reality check:
- Gemini free tier is tiny: ~10 requests/min and ~20/day for Flash-Lite. An always-on agent blows through that in seconds. For real use, enable billing (paid tier is thousands/min; a day of 1Hz Flash-Lite watching is a few dollars).
- Two clocks, running in parallel. Capture fills the buffer in its own
coroutine and never pauses while the model thinks.
SACCADE_CAPTURE_FPS= how fast frames stream in (cheap, no API);SACCADE_GLANCE_FPS= how often we call the model. Ifglance_fps >= capture_fps, every frame gets glanced (lockstep; use this for replay). If lower (e.g.0.14≈ every 7s to fit a rate limit), the loop samples the latest while the buffer keeps a dense clip for Focus.SACCADE_GLANCE_FPS=0glances as fast as it can. - Glance downscales its input (
SACCADE_GLANCE_MAX_DIM, default 768): peripheral vision is low-acuity, so the cheap always-on tier doesn't need full resolution to spot that something changed. Focus always gets the full-res frame, since it reasons carefully and runs rarely. Fewer tokens where it's frequent, full detail where it matters. - Adaptive cadence (
SACCADE_ADAPTIVE_CADENCE, on by default). Instead of a fixed rate, Glance emits how soon it should look again: quiet scene → check back in seconds; something happening → every tick. The model sets the interval, so total calls (and cost) fall with no hand-coded rules. It only ever slows (never faster thanSACCADE_GLANCE_FPS) and clamps toSACCADE_GLANCE_MAX_INTERVAL(default 15s). Seeloop.py.
ReplaySensor plays back a folder of images, so you can iterate on behavior
deterministically: no live feed, no quota:
SACCADE_SENSOR=replay SACCADE_REPLAY_DIR=frames/ uv run python -m saccadeThe genuinely hard part is when to speak up (Glance's salience judgment). Evals
make it measurable: a set of labeled scenes (evals/scenes.json) and a scorer.
SACCADE_GLANCE_BACKEND=gemini GEMINI_API_KEY=... uv run python -m saccade.evalsPrints per-case hits/misses and precision / recall / accuracy. Tweak the Glance
prompt, re-run, watch the numbers move. Cases use scene (text, for the stub)
or image (a file path, for real vision models); drop labeled frames in and
score a real model the same way.
uv pip install pytest && python -m pytest -qv0.1: end-to-end loop validated live. RTSP camera → cheap-tier judge (1Hz) → escalate → bigger model → audio out. Local + hosted backends, swappable sensors
- speakers, structured output, episodic memory, evals, tests.
v0.2: hearing (mic sensor), sight+sound fused in one glance (av sensor),
per-device speaker output, adaptive cadence (Glance paces its own attention),
concurrent Focus (Glance keeps watching while the big model reasons).
Next:
- More audio backends. Only Gemini hears today; add others as they ship native audio input.
- Full duplex / barge-in. Interruptible speech: belongs with a realtime streaming path, not the turn-based loop.
License: MIT.