Dogs
A dog is a process the shepherd runs for its own sake, not for yours. It watches the flock instead of being part of it. Two ship inside theshep binary: metrics, a Prometheus endpoint, and bark, webhook alerts. You can also pointshep adopt at a binary of your own and the shepherd will supervise it the same way.
A dog is not the same thing as a sheep with a shepherd channel: see the shepherd channel for that. A sheep opens fd 3 and talks newline JSON to the daemon that already owns it. A dog is a separate client: it connects toshep.sock and speaks the same wire protocol shep flock or shep describe would.
What a dog actually is
Underneath, a dog is not a second kind of supervised process. It's an ordinary sheep with a marker on its entry saying where it came from (built-in, or adopted). The kill ladder, the backoff curve, the restart budget, what Errored means: none of it branches on whether an entry is a dog. A dog that restarts is still a dog when it comes back, and a dog whose binary won't spawn shows up in shep dogs as Errored, the same way a broken sheep would.
shep flock prints the flock as two tables (sheep, then dogs) and shep dogs prints the second one alone. A wildcard selector (stop all, reload all, /regex/) never touches a dog; naming one exactly always does, which is what lets shep disable bark reach a dog while a sweep of everything else leaves it running.
Turning one on
shep enable <name>Writes shep.toml first, then starts the dog now if a shepherd is listening. Run it with nothing listening and it still exits 0: the config says the dog belongs, and the next shepherd that boots brings it up. Nothing here autostarts a shepherd on your behalf. Two verbs do, and neither is a dog verb: shep start and shep muster.
shep disable <name>The mirror: stops the dog if one is running and removes it from the boot list either way.
$ shep enable metrics NAME SOURCE SHEPHERD STATUS metrics built-in true online $ shep dogs ID NAME STATUS PID RESTARTS EXIT CPU MEM UPTIME SOURCE 1 metrics online 49313 0 - - 10.9M 2s built-in $ shep flock ID NAME STATUS PID RESTARTS EXIT CPU MEM UPTIME FOLD SMIT 0 web online 49290 0 - - 3.0M 2s - - Dogs ID NAME STATUS PID RESTARTS EXIT CPU MEM UPTIME SOURCE 1 metrics online 49313 0 - - 10.9M 2s built-in $ shep disable metrics
The dogs table shares its column order with the sheep table above it, so a dog and a sheep read the same way. SOURCE is the one column only dogs have, and FOLD/SMIT the two only sheep have. See terminal output.
A dog whose STATUS reads silent is running but has never spoken to the shepherd, usually because acargo install replaced its binary and the old process is still up. shep bleats <dog> has the refusal;shep restart <dog> is the fix. See terminal output.
Running enable and adopt at once from a provisioning script is safe: each takes an exclusive lock on shep.toml for its whole read-edit-write, so the second waits its turn.
Configuration lives on the socket, not the environment
A dog's settings live under [dog.<name>] in shep.toml, and they never travel through the dog's environment. Instead the dog connects to the socket and asks for its own section over the wire. The daemon reads the file fresh for every such request rather than serving a cached copy.
An environment variable is readable from the process table, inherited by every child the dog spawns, and captured into a crash dump. [dog.bark.sinks] routinely holds a webhook URL, and a webhook URL is a bearer credential. Keeping the whole section off the environment means nobody has to remember which dog's config is sensitive.
Editing [dog.<name>] does not reach a dog that's already running. It asked for its section once, at startup, and nothing pushes it a new one. shep disable <name> && shep enable <name> is what re-reads it: a fresh process asking a fresh question.
The metrics dog
shep enable metrics serves Prometheus exposition text over plain HTTP, bound to 127.0.0.1:9615 by default:
[dog.metrics] bind = "127.0.0.1:9615"
Nothing accumulates between scrapes. Every request rebuilds the reading from a fresh flock listing and a fresh host sample, verified by scraping it directly:
$ curl 127.0.0.1:9615/metrics
shep_sheep_memory_bytes{sheep="web",id="0",fold=""} 3178496
shep_sheep_restart_total{sheep="web",id="0",fold=""} 0
shep_sheep_status{sheep="web",id="0",fold="",status="online"} 1
shep_dog_up{dog="metrics",source="built-in"} 1
shep_daemon_up{version="0.1.0"} 1
shep_daemon_pid 39898
shep_host_memory_total_bytes 51539607552shep_sheep_cpu_percent Tree CPU as a percentage of one core, over the last sampling window.shep_sheep_memory_bytes Tree resident set size in bytes.shep_sheep_restart_total Restart count since registration.shep_sheep_uptime_seconds Seconds since this sheep's last successful start.shep_sheep_status 1 for the status this sheep is currently in, 0 for every other status.shep_dog_up 1 when this dog is online, 0 otherwise.shep_daemon_up Always 1: the scrape reached the shepherd.shep_daemon_pid The shepherd's own pid, so a restart is visible as a step change.shep_host_memory_total_bytes Total physical memory on the host.shep_host_memory_used_bytes Memory in use on the host, as the platform reports it.shep_host_processes Number of processes running on the host, the flock included.shep_host_uptime_seconds Seconds since the host booted.A reference Grafana dashboard built against this exact metric set ships in assets/grafana/. The default bind is loopback on purpose. Every series above carries a sheep's name as a label, and on plenty of hosts that's the name of an internal service. Widening bind to 0.0.0.0 is one config line away, but metrics won't make that call for you by shipping wide and asking you to lock it down.
The bark dog
shep enable bark subscribes to the shepherd's event bus and delivers to named webhook sinks:
[dog.bark.sinks]
oncall = { kind = "discord", url = "https://discord.com/api/webhooks/..." }
audit = { kind = "json", url = "https://example.internal/hook" }
[[dog.bark.rules]]
on = "gave_up"
sinks = ["oncall", "audit"]
[[dog.bark.rules]]
on = "restart_rate"
restarts = 5
within = "2m"
sinks = ["oncall"]Leave [dog.bark.rules] out and bark doesn't go quiet: one rule is built in by default, firing on every configured sink whenever a sheep reaches Errored. That's the alert that must not be missed, so it needs no opt-in. restart_rate is the opposite: an early warning for a sheep flapping but not yet Errored, and it's opt-in because that's exactly the kind of thing that pages someone at 3am for a blip if an operator didn't choose the threshold themselves.
Verified directly: enable bark with [dog.bark.sinks] absent entirely and it does not run quietly with nowhere to send alerts: the default rule still exists with zero sinks to route to, bark logs rule 0 routes to no sink at all on every restart attempt, and lands on Errored. A dog needs at least one configured sink before shep enable bark is worth running.
Every rule debounces per subject, five minutes by default, never globally. A global debounce would mean the second sheep to go down during an incident goes silent, which is usually the incident's most interesting fact. The bus itself (tokio::sync::broadcast) drops what a lagging subscriber can't keep up with, so bark also polls the flock's state on a timer (30s by default) and evaluates the same rules against that snapshot. A dropped frame triggers an immediate poll instead of waiting. The two routes share one debounce record per subject, so an Errored seen by both fires once, not twice.
Every fired alert is appended to $SHEP_HOME/barks.jsonl, a byte-capped ring (1MiB by default) that evicts the oldest record first: the file shep barks reads. It's written through an advisory file lock, never truncated in place, because two different processes append to it: the bark dog when a rule fires, and the shepherd itself when an enabled dog exhausts its own restart budget.
Reading the alert history
shep barks [--tail N]Prints that file, newest last, with --tail to see only the last few. It reads barks.jsonl straight off disk and never connects to the shepherd, which is the whole point: the history is on disk precisely so it outlives the shepherd, and the moment this verb exists for is an operator reading it after a crash. Same precedent as shep flush --daemon, which also works on files rather than through the socket.
$ shep barks WHEN RULE SUBJECT MESSAGE SINKS 2026-08-29 21:06:35 daemon flaky dog flaky exhausted its restart budget: 15 restarts against a budget of 16 -
SINKS names each sink the alert was delivered to, with (failed) appended to any that refused it, so a delivery failure is visible in the table you are already reading rather than only in --format json. It never carries the sink's own error text, which can quote a webhook's HTTP response and would widen an already-tight column for something the JSON already has in full. A - means the shepherd wrote the record itself, with no sinks and no webhook code behind it, which is the case above.
A line that won't parse costs you that one record, not the history. A writer that died mid-append, or a record from a newer shep, is skipped and the rest still prints. This file is read during an incident, and refusing the whole ring over one bad line would be the wrong failure mode.
Writing your own
shep adopt <path> [--name <name>]Third-party extension. The path can be given as-is, with a leading ~/, or as a bare name already on $PATH (where cargo install puts one). --name is optional and defaults to the binary's file stem with a leading shep- stripped, the way cargo strips cargo- from its own external subcommands. Vets the binary once, at adopt time: refusing a path that doesn't resolve to anything that exists, isn't a file, has no execute bit, or is world-writable, and refusing a name that already belongs to a built-in verb or alias, since such a dog could never be reached. Passing all of that, it actually spawns the binary with no arguments and kills it immediately, because the only honest way to know whether this kernel can exec a file is to ask this kernel.
shep rehome <name>disable's counterpart for a third-party dog: stops it if running and forgets the registration entirely, rather than leaving it disabled-but-known.
$ shep adopt ./bin/my-watchdog --name watchdog NAME SOURCE SHEPHERD STATUS watchdog adopted true online $ shep rehome watchdog NAME SOURCE SHEPHERD STATUS watchdog adopted true stopped
Once adopted, shep watchdog [args...] runs it directly — git and cargo's own precedent (git foo runs git-foo), resolved against adopted dogs only, never a $PATH scan. It's a second invocation mode, distinct from the shepherd's own: an adopted dog the shepherd starts gets no argv and two environment variables; a dog you name on the command line gets whatever you typed after it, plus those same two. A built-in dog is the exception, and not one you can write: the shepherd starts it as shep dog <name>. A built-in verb or alias always wins over a same-named dog.
What gets recorded in shep.toml is the canonicalized, absolute path (never whatever relative path you typed) because the daemon may resolve it again after a reboot from a different working directory than the one adopt ran from.
The wire a third-party dog speaks is the client protocol every other client uses: connect to the Unix socket at $SHEP_HOME/run/shep.sock, send Hello, wait for HelloAck, then send Request::DogConfig { name } to fetch your own section as opaque text. Shep sets two variables of its own on a dog: $SHEP_HOME, which is how it finds that socket in the first place, and $SHEP_DOG_NAME, which is the name to put in that request. No [dog.<name>] value rides along beside them.
Put that name in the Hello too, as dog_name. It is optional, and nothing breaks without it right up until the shepherd is replaced by one your binary is too old to speak to. A refused handshake never reaches a request, so the name in DogConfig is unreadable at exactly the moment it is needed: which dog to restart. With it, the shepherd restarts you once from the binary on disk, which fixes the ordinary case where the package already replaced your file and the running process is merely old, and reports you stale rather than looping if that restart is refused too. shep daemon reload prints that report to the operator, after your reconnect rather than before it: what the old image knew about you described a process that was about to stop existing. Without it you go quiet and nothing on either side says why.
A dog written against shep-client gets both halves from ReconnectingClient::connect_as_dog, which fills the name in and also re-establishes the connection when the shepherd is replaced. Client does neither, deliberately: the CLI uses it, and a shep stop that silently retried could stop a sheep twice.
That is what shep daemon reload asks of a dog. A dog is carried across the reload the way a sheep is: the process is a child of a shepherd whose pid does not change, so it keeps its own pid and its restart count stays where it was. What does not survive is the accepted connection, which dies with the old image — so a dog that does not dial again is a live process holding a dead socket, alive on every column a listing has and answering nothing. The metrics dog is measured holding its pid and restarts 0 across six reloads while still serving a scrape.
The bark dog is the exception, and it is on the list to fix. Its subscription belongs to one connection, so the stream ends when that connection does and the dog exits; autorestart replaces it, which costs one restart per reload on a dog that is otherwise healthy. It comes back on its own every time, and its restart budget starts a fresh window with each shepherd, so this is a count that reads wrong rather than an outage.
Those two are what shep adds, not the whole environment. A dog is a supervised process like any other, so it also starts from the small base every sheep gets: PATH, plus whichever of HOME, USER, LANG and TZ the shepherd itself has, plus SHEP_INSTANCE. Nothing from [dog.<name>] is in there.
Read $SHEP_DOG_NAME rather than hardcoding a name. It holds the name you are registered under, which is the operator's --name if they gave one and otherwise your own file stem with a leading shep- stripped. Shep sets it every way it runs you: supervised, shep <name>, and the exec probe during adopt itself. Getting the name wrong is silent, which is the whole reason the variable exists: a DogConfig for a name nobody adopted comes back empty, exactly what a registered dog with no section gets, so a one-character mismatch discards the operator's whole section, uses every default instead, and prints nothing on either side. If the variable is absent you are not being run by a shep that sets it, and the fallback is the pid: your process knows its own, ListFlock reports one per entry, and the entry marked as a dog carrying your pid is you.
Never send Request::Flush as part of rotating anything. Its name reads like settling a buffer and it does the opposite of what a rotator wants: it flushes what is pending and then truncates the recorded paths. That is shep flush, an operator deliberately emptying logs. Reaching for it before a rename deletes the lines you were about to rotate. Rename, then Reopen: that shape has no such hole.
There is no sandbox, and it would be a mistake to assume one. An adopted dog runs at the shepherd's own trust level, with no isolation beyond it: the same trust an ordinary sheep in your Flockfile already has. Adopting a dog does not grant anything beyond what any process your own config already starts could do.
Somebody else has probably already written the dog you're about to build; see the community list before you start, or run shep dogs --available to browse the same index without leaving a terminal.
Answering --version
A dog answers --version on stdout and exits 0. Two numbers come back, and they answer different questions.
shep-protocol Whether this dog can handshake with this shepherd at all. A difference means it can't connect until one side moves.shep-log-rotate 0.1.3 shep-protocol: 2
Line 1 is <name> <version>. Shep takes the last whitespace-separated field as the version and ignores the name, so a crate whose name differs from the dog's registered name is fine. That's the line clap already prints for free. Every later line is <key>: <value>, one pair to a line, and shep-protocol carries in decimal the PROTOCOL_VERSION the binary was compiled against.
Unknown keys, blank lines and the order of the key lines are all ignored. Keys beginning shep- are reserved, so a third number can get its own line later without breaking a parser that predates it. Put keys of your own under a prefix of your own.
Lines rather than JSON, because a human runs --version far more often than shep does, and because a stranger writing a dog in a language shep has never seen gets two printf calls right on the first try. Hand written JSON is where the quoting and the trailing comma go wrong, and nothing here nests.
Emitting it needs no dependency a dog doesn't already have:
if std::env::args().nth(1).as_deref() == Some("--version") {
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
println!("shep-protocol: {}", shep_client::PROTOCOL_VERSION);
return;
}Answering is optional and stays optional. Dogs predating the convention are still adoptable, and a dog that doesn't answer is never refused for it: its protocol is simply unknown.
What shep adopt does with the answer
shep adopt asks the candidate before it records anything. The vet was already spawning the binary to prove this kernel can exec it, so the question costs one argument on a process that was going to start anyway.
shep-protocol this shep doesn't speak Refuses, before shep.toml is touched.The refusal names both numbers and both ways out:
/usr/local/bin/shep-otel: this dog was built for shep protocol 1, and this shep speaks 2; reinstall the dog without --locked so it builds against the current shep-core, or run a shep that speaks 1
Only a stated protocol can refuse an adopt. The version is never compared with anything, because a third-party dog's crate version has no relationship to shep's own: shep-log-rotate 0.1.3 against shep 0.1.24 is the ordinary case, not a skew, and comparing the two would report every dog that exists.
A candidate gets one second to exit and another to have its output read, so two seconds is the worst case rather than one. It is killed either way, so a dog that ignores --version and runs costs that second and is adopted with an unknown protocol. It can't hang the adopt that's vetting it.
None of the answer is written down. [daemon] adopted_dogs records the path and nothing else, and a protocol stored at adopt time would be a copy of a number that can change on disk with nothing watching. Shep asks the binary again rather than remembering what it said.
What shep restart <dog> does with the answer
cargo install replaces a file and never touches a process, so a dog upgraded on disk leaves a working system: the dog running now is the old binary, still connected, still doing its job. The two only meet at the next restart, which may be days away and for a reason that has nothing to do with the upgrade.
shep restart <name> is that restart, so it asks the same question first:
notice[dog_binary_skew]: `log-rotate`'s binary at /usr/local/bin/shep-log-rotate was built for shep protocol 3, and this shep speaks 2; restarting it brings it back on that binary, unable to connect. Run a shep that speaks 3, or reinstall the dog against protocol 2, and restart it again
Then it restarts the dog. This is a warning and never a refusal: the operator asked for the restart, the binary on disk may be exactly what they just installed, and there are two ways out of the state rather than one, so the message names both and picks neither.
shep-protocol this shep doesn't speak Warns, then restarts.Unknown is not stale. Every dog written before this contract is in the last two rows, and a line on stderr for each of them is how an operator learns to skip the one that matters.
Three things are never asked at all. A built-in dog has no binary of its own, so there is nothing to be stale. A selector that sweeps rather than names, all or a /regex/, doesn't reach a dog in the first place. And a dog named by id rather than by name is restarted without a check, because looking up its name would cost a round trip before the restart the operator asked for.
The cost is the same second adopt spends, and it's paid only by a restart that named an adopted dog. A binary that hangs is killed when the second is up and the restart goes ahead unwarned, so the slowest a dog can make shep restart is two seconds, never indefinitely.
A named restart briefly runs your dog twice. To read the binary on disk, shep runs it with --version, and a dog that does not recognise that flag ignores it and starts doing its ordinary job instead, with SHEP_HOME pointing at the live shepherd. So a rotator can rotate once, and a bark dog can open a second subscription, before the process is killed.
Only dogs that ignore the flag are affected. A dog that recognises--version and exits, even without naming a protocol, does no work: its protocol is unknown and nothing overlaps. The trade is deliberate: the command is about to restart that dog anyway, so the only question is whether it briefly overlaps itself, and the alternative is not asking at all. Adding --version to your dog removes the overlap, since a dog that answers exits immediately.
Why the binary is the only thing that can answer
A dog's crate version doesn't imply its protocol, and neither does knowing that somebody installed it. Which shep-core a dog ends up carrying depends on how it was built:
cargo install <dog> shep-core is re-resolved to the newest compatible release, so the protocol is current.cargo install --locked, and most CI The shipped lockfile is honoured, so the protocol is whatever was pinned on publish day.Measured 2026-08-31: shep-log-rotate 0.1.3 installed plain compiled shep-core 0.1.24, ignoring the packaged lockfile, while the same crate built --locked produced a protocol 1 dog. Both published dogs were shipping a lockfile pinning a protocol 1 shep-core that day, and both repositories' CI had been red on it for two days. The crate version was identical either way. That's the whole argument for asking the binary: nothing outside it knows.
The built-in dogs are outside this
metrics and bark are not separate binaries. The shepherd starts them as <its own binary> dog <name>, so a built-in dog is the shep binary that spawned it and cannot skew from it on disk. There's no question here for a contract to answer:
$ shep --version shep 0.1.24 $ shep dog metrics --version shep-dog 0.1.24
Neither prints a shep-protocol line, and that isn't a gap to close. The protocol a built-in dog speaks is the shepherd's own, because it is the shepherd's binary.
What this doesn't catch
Two dogs can agree on the protocol and still be different code. RpcError gained a public daemon_version field inside the 0.1.x range; its fields are public and it has no constructor, so every literal built outside shep-client stopped compiling while PROTOCOL_VERSION stood still. Protocol equality is necessary and not sufficient.
Closing that is deferred, and the reason is that --version can't close it. A break of that kind is source level: it lands when the dog is compiled, in the dog's own repository, and a dog nobody rebuilt keeps running. Shep has no table of which shep-client versions build against which daemon, and a version comparison standing in for one would report dogs that are fine. What catches it is the dog's own CI building against a current shep-core, which is where both published dogs' breakage was already visible and unread.
When a dog dies
If an enabled dog exhausts its own restart budget and lands on Errored, the shepherd writes a record straight into barks.jsonl itself, independent of whether the bark dog is even running. That matters for one reason: bark has no webhook code of its own for reporting its own death, so if bark is the one that died, nothing pages anyone about it over Discord or Slack. What you get instead is a local record (shep barks after the fact) and the metrics dog's shep_dog_up gauge dropping to 0 for that dog's name.
Nothing watches across dogs, and that's not an oversight: the supervisor that restarts a crashed dog has no idea what a "dog" is beyond the marker on its entry, so no dog is positioned to watch another dog the way the shepherd watches all of them. shep barks and the metrics endpoint are the two places that say a dog went down.