docs · pre-release
Menu
Dogs / Dogs

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 the shep binary: metrics, a Prometheus endpoint, and bark, webhook alerts. You can also point shep adopt at a binary of your own and the shepherd will supervise it the same way.

note
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 to shep.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
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.

Takes a built-in name or one shep adopt has recorded, and refuses anything else before it writes. A refused enable leaves shep.toml untouched. The check reads [daemon] adopted_dogs rather than the shepherd, so it holds with nothing listening.

shep disable
shep disable <name>

The mirror: stops the dog if one is running and removes it from the boot list either way. It carries no name check of its own, deliberately, so it is what takes a hand-written enabled_dogs entry back out.

$ 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  CFG  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 guard is on the verb, not on the file. Write a name into [daemon] enabled_dogs by hand and the shepherd still reads it at boot, still treats anything outside [daemon] adopted_dogs as built-in, and spawns shep dog <name> for it once per restart until the budget is spent. From shep dogs that reads as a climbing restart count against a SOURCE of built-in, for a name that is not one; shep bleats <name> is where the refusal itself shows up.

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 CFG, FOLD and SMIT the three only sheep have. See terminal output.

A dog whose STATUS reads silent is running but has never spoken to the shepherd, usually because a cargo 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. dogs.toml has its own lock on the same terms, held by both things that write it: the migration a boot runs once, and the config pane's own edits. A boot holding both takes shep.toml's first.

When a dog starts

After the flock, by default. A metrics dog that came up first would spend the boot answering for a flock that is not running yet.

A log-rotation dog is the other way round, since it has to be running before a sheep starts writing. Put its name in [daemon] boot_first_dogs and it runs ahead of every sheep:

$SHEP_HOME/shep.toml
[daemon]
enabled_dogs    = ["metrics", "log-rotate"]
boot_first_dogs = ["log-rotate"]

A name here that is not in enabled_dogs is inert: promoting a dog does not enable it. This is shep.toml rather than dogs.toml for the same reason adopted_dogs is: the [<name>] table in dogs.toml is passed through to the dog itself, and a shep-owned key inside it would collide with a third-party dog's own schema. See boot order for the stages this positions a dog in.

[daemon] channel_dogs lives there too, written by shep adopt for a dog that asks for the shepherd channel. Such a dog takes shep trigger.

Configuration lives on the socket, not the environment

A dog's settings live under [<name>] in $SHEP_HOME/dogs.toml, hand-editable and separate from 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. [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.

careful
Editing [<name>] in dogs.toml 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.

Three places a dog's config can live

[<name>] in dogs.toml, above, is the dog's own settings. A sheep's Flockfile entry can also carry [app.dogs.<name>], one table per dog for that sheep alone: shep stores it and hands it to the dog it names, reading nothing inside. See reading a sheep's own table for how a dog gets it.

A third place, [dog.<name>] at the top level of a Flockfile, is read and discarded: shep parses it so the file still loads, but keeps nothing from it. It exists for a dog that reads its own Flockfile directly rather than asking shep for a section.

Live updates without a restart

A dog can subscribe instead of waiting to be bounced. Shep publishes on config.dog.<name> when it changes a dog's section itself, and a dog holding a subscription re-asks for its section and decides what the change means: swap the values, rebind a listener, or ask for its own restart. The frame says that the section changed and nothing about what is in it. Bark subscribes and swaps in place, since sinks and rules are pure data with no OS resource attached. Two things publish today: the boot that moves a section out of shep.toml, and a write from the dog config pane, which is why an edit made there reaches a running bark without bouncing it. A hand edit still needs the stop and start above.

careful
A dog that restarts itself on a config change has to say so in its own log. Nothing else can tell that apart from a crash loop, and the restart count is the column an operator reads as instability.
careful
A section that used to live in shep.toml migrates to dogs.toml once, on the first boot of a shep carrying this move, and it prints which dogs moved. Any boot does it, including shep runtime and shep dev. If a name carries values in both files, the daemon refuses to boot until one of the two sections is deleted, and shep daemon reload refuses before it signals the shepherd it was going to replace. An empty [dog.<name>] is not a value and refuses nothing. A dog present in only one file migrates or starts normally.
What a provider dog pushes

A dog can also push, not only be configured. A provider dog sends Request::PutSecrets with its own registered name as the namespace, replacing whatever that namespace held for one environment rather than merging into it. Pushed values are cached to $SHEP_HOME/secrets-cache.json by default, so a reboot does not leave every sheep that reads them waiting on the dog and a network round trip; persist = false in the dog's own section of dogs.toml turns that off. See Secrets for the token that reads a pushed value, and for what a namespace does and does not defend against.

careful
A namespace can legally contain a dot, and an unquoted section header does not mean what it looks like. [vercel.prod] in dogs.toml parses as a nested TOML table, key prod inside table vercel, not as one literal section named vercel.prod. A namespace with a dot in it needs a quoted header, ["vercel.prod"], or the section never matches and persist silently falls back to its default of true. This is ordinary TOML, and the same trap catches any dog name with a dot in it, not only a provider's namespace.

The metrics dog

shep enable metrics serves Prometheus exposition text over plain HTTP, bound to 127.0.0.1:9615 by default:

dogs.toml
[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 51539607552
Full metric reference
MetricMeaning
shep_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:

dogs.toml
[bark.sinks]
oncall = { kind = "discord", url = "https://discord.com/api/webhooks/..." }
audit = { kind = "json", url = "https://example.internal/hook" }

[[bark.rules]]
on = "gave_up"
sinks = ["oncall", "audit"]

[[bark.rules]]
on = "restart_rate"
restarts = 5
within = "2m"
sinks = ["oncall"]

A sink URL can't carry credentials. https://user:[email protected]/ is refused when dogs.toml is read, not when a rule first fires: nothing here sends an Authorization header, so the pair could only be discarded or sent as part of a hostname. Discord and Slack put the secret in the path instead, which is why the whole URL is a credential.

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

careful
Verified directly: enable bark with [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
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.

$ 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

A dog is an ordinary binary that answers a few questions on stdout and speaks the dog protocol on a socket the shepherd hands it. The whole contract, including what --version and --schema have to answer and why the binary is the only thing that can answer them, is its own chapter: Writing a dog.

What shep writes into a dog's own log

A dog's log is the first place anyone looks when a dog misbehaves, and it used to hold only what the dog itself said. Everything shep had to say about it went to shepd.err.log, which is not the file shep's own error messages tell you to open. Shep now writes its own account into the dog's log too, marked [shep] so it can't be mistaken for the dog's output.

2026-09-02T14:22:31.412+02:00 [shep] shep started this dog; its process is pid 5512
2026-09-02T14:22:31.480+02:00 [shep] shep accepted this dog's handshake; it is registered with this shepherd as `log-rotate`, on protocol 8
2026-09-02T14:22:31.492+02:00 rotating web-0-out.log (12.4 MiB)

Six things get written: the spawn and the resolved binary path, an accepted handshake (once, the first time this shepherd hears from the dog, not on every reconnect), a refused handshake with both protocol numbers, the warning when a dog goes quiet, the verdict when shep gives up on it, and the exit code or signal when its process stops. shep bleats <name> --follow sees the same lines live, marked the same way, interleaved with the dog's own output in arrival order.

When a dog stops answering

A dog that is running but never handshakes gets one restart from disk and then a verdict. That verdict says what the shepherd actually watched happen, because the three cases it reports have opposite fixes.

  • Nothing has ever connected from the dog's process. The dog isn't reaching the socket. Rebuild or reinstall it.
  • Its process has connected, and never named a dog in its handshake. The dog is talking to shep and may be serving every request it's asked; what it doesn't do is say who it is. Reinstalling the same build won't change that: it's built against shep-client older than 0.1.23, or it calls Client::connect where it should call ReconnectingClient::connect_as_dog. Rebuild it against a current shep-client.
  • Shep couldn't tell which process opened the connections. Windows has no peer identity by design, and some unix platforms decline to report a pid. Neither of the two above is ruled out, so shep names both and tells you to read the dog's own log: a dog that can't reach the socket says so there, and one that's merely anonymous doesn't.

Every one of them ends with a command to run. The one before them said the binary on disk cannot talk to this shep either in all three cases, which sent one operator through two days of reinstalling a dog that was connected and serving requests the whole time.

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.