Roost is a camera pipeline you own end to end. The Roost device (Raspberry Pi) captures from a USB camera, a Rust app exposes control over WebSocket, a Python script pushes snapshots to a private S3 bucket, a Lambda runs Claude vision on each new photo to describe and tag it, and a dashboard pulls it all together with live controls and search-by-contents. This guide walks through setting it up from a blank SD card to a working, searchable dashboard.
Capture app
Runs on the Roost device. Owns the camera, streams MJPG, takes commands over WebSocket.
S3 uploader
Runs on the Roost device. Watches the output folder, uploads new frames, deletes the local copy.
Vision recognition
Fires on each new photo. Claude describes and tags it; results go to DynamoDB.
Dashboard
Runs anywhere. Controls the camera, shows photos, searches by what's in them.
00 What You'll Need
Get the code first. Everything below assumes you have the repo handy:
github.com/BottleBlueLLC/Roost
01 Flash the Device and SSH In
Use an adapter and insert the microSD card into your computer. Navigate to the official Raspberry Pi Imager. Select Raspberry Pi OS (other) then choose Raspberry Pi OS Lite (64-bit); you don't need a desktop for a headless camera. Set a hostname (this guide uses roost), create the roost user with a password, and enable SSH with password authentication unless you have already configured access keys. Finally, click write. This bakes everything in so you never need a monitor or keyboard.
Once complete, remove the card from your computer and insert it into the Roost device, connect Ethernet, and power on. Give it a minute to boot, then open a terminal (not command prompt) and connect from your computer:
ssh roost@roost.lan
Type yes and hit enter to add to known hosts. If roost.lan doesn't resolve, use the Roost device's IP address instead (check your router's device list).
02 Confirm the Camera Is Seen
Install the V4L utilities (it may already be installed) and list video devices:
sudo apt update
sudo apt install -y v4l-utils
v4l2-ctl --list-devices
You should see your camera with one or more /dev/videoN nodes. Now check what formats and resolutions it actually supports:
v4l2-ctl --device=/dev/video0 --list-formats-ext
03 Find Your Camera's Stable Path
Roost identifies the camera by a substring of its /dev/v4l/by-path entry, not a raw /dev/videoN number, since those numbers can shift between boots. List the entries:
ls -la /dev/v4l/by-path
Find the line pointing at your camera's video node. It looks something like this:
platform-xhci-hcd.1-usb-0:1:1.0-video-index0 -> ../../video0
Note a unique chunk of that name, for example usb-0:1:1.0-video-index0. You'll put it in the config in the next step.
04 Build the Capture App
Install the build dependencies. The camera app compiles libjpeg-turbo from source, so it needs cmake and nasm in addition to the usual toolchain:
sudo apt install -y build-essential pkg-config clang libclang-dev \
libjpeg-turbo-progs libturbojpeg0-dev cmake nasm
Install Rust via rustup. The version in apt is often too old for some of the crates here, so don't use apt unless you know this issue has been resolved:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
Enter 1 and press enter when prompted to proceed with standard installation:
1) Proceed with standard installation (default - just press enter)
2) Customize installation
3) Cancel installation
>1
cargo --version
Copy the files onto the Roost device using scp, rsync, flash drive, flashzilla (whatever works best for you):
scp -r /Users/studio/Downloads/Roost-main roost@roost.lan:/home/roost/
Copy the example config and edit it:
cd Roost-main
cp config.example.toml config.toml
nano config.toml
Set path_identifier to the chunk you noted in step 3, and pick your resolution. For a 4K camera use:
path_identifier = "usb-0:1:1.0-video-index0"
default_width = 3840
default_height = 2160
jpeg_quality = 95
Build it. The first build takes a while on the Roost device, since it's compiling the full dependency tree plus libjpeg-turbo:
cargo build --release
Run it:
./target/release/camera
You should see it open the device, report the streaming resolution, and start logging a frame count. Both WebSocket listeners come up on ports 8080 and 8081. Leave it running in this terminal.
05 Create the S3 Bucket and a Scoped Key
In the AWS console, create a private S3 bucket. Keep Block all public access turned on; the dashboard reaches photos through presigned URLs, not public access. Note your bucket name and region.
Do not use your root account keys. Create a dedicated IAM user with access scoped to just this one bucket. Make an IAM policy with this JSON, replacing the bucket name with yours:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*"
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::YOUR-BUCKET-NAME"
}
]
}
Create an IAM user, attach that policy, then generate an access key for it. Copy the access key ID and secret somewhere safe; the secret is shown only once.
06 Set Up the Uploader
On the Roost device, put the AWS credentials in a named profile so they don't collide with anything else you have configured:
mkdir -p ~/.aws
nano ~/.aws/credentials
[roost]
aws_access_key_id = YOUR_ACCESS_KEY_ID
aws_secret_access_key = YOUR_SECRET_ACCESS_KEY
nano ~/.aws/config
[profile roost]
region = your-region-here
Open ~/Roost-main/s3_uploader.py and set BUCKET_NAME to your bucket, located in the config section under the imports. Then create a virtual environment and install boto3:
cd ~/Roost-main/
python3 -m venv uploader-venv
source uploader-venv/bin/activate
pip install boto3
Run it in its own terminal, telling boto3 which profile to use:
(uploader-venv) roost@roost:~/Roost-main $ AWS_PROFILE=roost python3 s3_uploader.py
It watches the camera's output folder, uploads each new .jpg to S3, and deletes the local copy once the upload succeeds. If the upload fails, it leaves the file in place and retries on the next pass, so you don't lose captures to a flaky connection.
07 Run the Dashboard
The dashboard can run anywhere with network access to both AWS and the camera. Running it on your laptop keeps the WebSocket connections off the Roost device's SSH session. It has a small Flask backend (which holds the AWS credentials and hands the page short-lived presigned URLs, so the bucket stays private) and a single-file React frontend. I'll be running it on a Windows computer.
Set up the same named AWS profile on this machine, in the same ~/.aws/credentials and ~/.aws/config format. Then, in the viewer folder:
cd viewer
python3 -m venv venv
source venv/bin/activate # on Windows: .\venv\Scripts\Activate.ps1
pip install flask boto3
python server.py
Open http://localhost:5000 in a browser. In the top bar, click the host pill and enter your camera's address (for example roost.lan), then press Enter. The two status pills go green once the page connects to the camera's WebSocket ports.
08 Take Your First Snapshot
With all three pieces running, click Take snapshot in the dashboard. The flow you just triggered:
- The dashboard sends a
snapshotcommand over WebSocket to the camera. - The camera grabs the next frame, overlays a timestamp, and writes a JPEG to its output folder.
- The uploader sees the new file, pushes it to S3, and deletes the local copy.
- The dashboard's gallery polls S3 and the photo appears within a few seconds.
If it doesn't show up, check the camera terminal for a Saved line and the uploader terminal for an Uploaded line. Whichever one is missing tells you which stage to look at. Make sure the frames directory and AWS bucket indicated in s3_uploader.py exist and are accessible.
09 Add AI Recognition: DynamoDB and the Vision Lambda
This is where Roost stops being a camera and starts being searchable. Every new photo gets sent to Claude, which returns a description and a set of tags, stored in DynamoDB so the dashboard can search by what's actually in the frame.
First, create the DynamoDB table. This uses a single-table design: generic PK/SK keys plus a secondary index (GSI1) for tag lookups, which means it already supports multiple cameras and users without a redesign later.
aws dynamodb create-table \
--table-name Roost \
--attribute-definitions \
AttributeName=PK,AttributeType=S \
AttributeName=SK,AttributeType=S \
AttributeName=GSI1PK,AttributeType=S \
AttributeName=GSI1SK,AttributeType=S \
--key-schema \
AttributeName=PK,KeyType=HASH \
AttributeName=SK,KeyType=RANGE \
--global-secondary-indexes \
"IndexName=GSI1,KeySchema=[{AttributeName=GSI1PK,KeyType=HASH},{AttributeName=GSI1SK,KeyType=RANGE}],Projection={ProjectionType=ALL}" \
--billing-mode PAY_PER_REQUEST \
--region ap-northeast-1
Next, get an Anthropic API key from console.anthropic.com (add a few dollars of credit; image analysis costs fractions of a cent each). Keep the key somewhere safe and never paste it into a terminal where it gets echoed back, or into a chat.
The vision Lambda lives in the vision/ folder of the repo. Package it together with the anthropic library. The platform flags matter: Lambda runs Linux on Python 3.11, so you must fetch Linux wheels, not whatever matches your own machine.
cd vision
python -m pip install -r requirements.txt -t package \
--platform manylinux2014_x86_64 --python-version 3.11 --only-binary=:all:
cp handler.py package/
cd package && zip -r ../function.zip . && cd ..
Create an execution role for the Lambda (so it can read S3, write DynamoDB, and log), then create the function. Replace ACCOUNT_ID with your 12-digit AWS account ID and supply your real API key in place of the placeholder:
aws iam create-role --role-name roost-vision-role \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam put-role-policy --role-name roost-vision-role \
--policy-name roost-vision-permissions \
--policy-document file://lambda-execution-policy.json
aws lambda create-function --function-name roost-vision \
--runtime python3.11 --handler handler.handler \
--zip-file fileb://function.zip \
--role arn:aws:iam::ACCOUNT_ID:role/roost-vision-role \
--timeout 60 --memory-size 512 \
--environment "Variables={DYNAMO_TABLE=Roost,CLAUDE_MODEL=claude-sonnet-4-6,ANTHROPIC_API_KEY=YOUR_KEY}" \
--region ap-northeast-1
Finally, wire the bucket so new uploads invoke the Lambda:
aws lambda add-permission --function-name roost-vision \
--statement-id s3-invoke --action lambda:InvokeFunction \
--principal s3.amazonaws.com \
--source-arn arn:aws:s3:::YOUR-BUCKET-NAME \
--region ap-northeast-1
aws s3api put-bucket-notification-configuration \
--bucket YOUR-BUCKET-NAME \
--notification-configuration file://s3-notification.json \
--region ap-northeast-1
Set-Content -Encoding UTF8, it adds a byte-order mark the AWS CLI rejects. Write them with [System.IO.File]::WriteAllText($path, $json, [System.Text.UTF8Encoding]::new($false)) instead, which produces UTF-8 without a BOM.
10 Test Recognition
The notification only fires on new uploads, so copy an existing object to a new key to trigger it, then check the table a few seconds later:
aws s3 cp s3://YOUR-BUCKET-NAME/some_frame.jpg s3://YOUR-BUCKET-NAME/test_trigger.jpg --region ap-northeast-1
aws dynamodb scan --table-name Roost --region ap-northeast-1
You should see an IMAGE record with a description and tags, plus one TAG record per tag. If the table is empty, read the Lambda logs to see what failed:
aws logs tail /aws/lambda/roost-vision --follow --region ap-northeast-1
11 Add Search to the Dashboard
The dashboard's search box queries the recognition tags. For it to work, the AWS identity the dashboard uses needs read access to the DynamoDB table and its index. If your dashboard credentials are a separate scoped user (recommended) rather than your admin identity, grant that user read access:
aws iam put-user-policy --user-name YOUR-DASHBOARD-USER \
--policy-name roost-dashboard-read \
--policy-document file://dashboard-read-policy.json
The policy grants only Query and GetItem on the Roost table and its index, nothing more, since the dashboard only ever reads. With that in place, type a tag into the dashboard's search box (try one you saw in the scan output) and the gallery filters to matching photos. Clicking a result shows Claude's description and the tags.
12 Make It Survive Reboots
Running things by hand in SSH sessions is fine for testing, but the moment you disconnect or the Roost device reboots, the pipeline stops. The repo includes a systemd unit file (camera.service) to run the capture app as a managed service that starts on boot and restarts on failure. A matching service for the uploader is a straightforward copy of the same pattern. Setting these up is the recommended last step once you're happy with how everything behaves.
Where to Go From Here
Once the basics work, a few directions are worth exploring. Fisheye lenses can be dewarped if you have the lens's distortion parameters. The device-discovery logic currently assumes one camera and could be generalized to several, which the DynamoDB schema already anticipates. And the WebSocket protocol is one-directional today; adding return messages would let the dashboard show real telemetry from the camera instead of just echoing what it sent.
Get the Code
The full project, the Rust capture app, the uploader script, and the dashboard, lives in one repo:
github.com/BottleBlueLLC/Roost
Start with the camera. Get a single frame off the device and onto disk before you worry about WebSockets, S3, or dashboards. Everything else is plumbing once that first frame exists.