docs · pre-release
Menu
Dogs / Writing a dog

Writing a dog

A dog is an ordinary binary. The shepherd starts it, hands it a socket and asks it two questions on the command line; everything else is the protocol it speaks back.

Writing your own

shep adopt
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 and kills it immediately, because the only honest way to know whether this kernel can exec a file is to ask this kernel. The --version probe below is that spawn, so the exec proof and the version answer cost one child rather than two.

shep rehome
shep rehome <name>

disable's counterpart for a third-party dog: stops it if running and forgets where its binary lived, rather than leaving it disabled-but-known. That is one file, the registration in shep.toml. The dog's own [<name>] section in dogs.toml stays, webhook URLs and all, exactly as disable leaves it: those settings are yours rather than the adoption's, so adopting the same dog again finds them waiting. Delete that section by hand when you want them gone. What disable keeps and this does not is the adopted path, so bringing the dog back takes a fresh shep adopt.

$ 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 [<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 on its own, deliberately: the CLI uses it, and a shep stop that silently retried could stop a sheep twice. It reconnects when asked, through Client::reconnect, which reports whether the daemon now answering is the one from before. The rest every dog repeats, from section to exit codes, is in shep_client::dogs.

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. Both built-in dogs are measured holding their pid and restarts 0 across ten reloads, the metrics dog still serving a scrape and the bark dog still delivering.

A subscription belongs to one connection too, so a dog that has one asks for a new one. Nothing re-arms it for you, on purpose: a stream that papered over the gap between one connection dying and the successor accepting a fresh Subscribe would leave you no way to know there had been a gap. Ask again, and treat the gap as a gap, since whatever the bus carried in between is gone and only the shepherd knows what changed.

A dog waits five seconds for a shepherd to answer again and exits if none does. Both halves matter. A dog that exits the moment its connection drops restarts once per reload, which reads as an unhealthy dog on the one column that judges it; a dog that waits forever is still running when an unrelated shepherd binds that socket later, and attaches itself to that one beside its own dog of the same kind. A handover never comes near five seconds: measured over ten shep daemon reload runs against a three-sheep flock, the socket turned away a full connect, handshake and request for 38ms at the shortest and 254ms at the longest. ReconnectingClient::link_lost and connected_within are what a third-party dog imposes that bound with; the client itself reconnects for as long as it is alive.

Check the budget at the top of your own retry loop. connected_within answers Ok for a link that is already up without consulting the budget, so it cannot be the thing that ends one. A shepherd that answers the handshake and then fails whatever you ask it, which one slow enough to miss your request's deadline does, leaves a loop bounded only by that call running for as long as it stays up, and a dog doing that stays online on every column a listing has while doing nothing. shep's own bark dog had this bug during development, written like this:

the wrong loop
loop {
    let left = budget.saturating_sub(started.elapsed());
    client.connected_within(left).await?;
    match client.subscribe(topics.clone()).await {
        Ok(stream) => return Ok(stream),
        Err(_) => continue,
    }
}

Check the budget at the top of your own loop and return when it is spent.

Three answers in the EXIT column, not two. A dog that gave up waiting exits 5. One a shepherd refused on protocol-version skew exits 6 without waiting, since the shepherd that refused is the party that can fix it. And a dog whose shepherd answered and then refused its request exits on that refusal's own code, 13 for an unsupported request, the same code it would have exited had the refusal come at startup rather than after a handover.

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, SHEP_NAME and SHEP_ENVIRONMENT. SHEP_NAME carries the same value SHEP_DOG_NAME does; SHEP_ENVIRONMENT is the same one a provider dog reads when it decides which environment to fetch and push, since a dog has no environment field of its own and falls back to [daemon] environment the same as any sheep would. Nothing from [<name>] is in there.

careful
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.
careful
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.
careful
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.

WhatAnswers
the version on line 1 Which build of the dog this is. A difference is reported, never refused.
shep-protocol Whether this dog can handshake with this shepherd at all. Below the floor it can't connect; above it, shep accepts it.
shep-channel Optional. true asks for the shepherd channel.
shep-log-rotate 0.1.3
shep-protocol: 8

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.

A dog written against shep-client answers this probe and the schema one below with a single call, as the first line of main:

main.rs
fn main() {
    shep_client::dogs::probe::<MyDogConfig>(
        env!("CARGO_PKG_NAME"),
        env!("CARGO_PKG_VERSION"),
    );
    // ...normal startup, reached only when this run is not a probe.
}

It prints the answer and exits when shep asked a question, and returns when it didn't, so nothing after it runs on a probe: put it before anything has been opened. The name and the version are arguments because env! expands where it's written, and they're the two facts only the dog knows. The flag names, the shep-protocol key and the number it carries all come from shep. That's the point of the call: they were hand-typed from this page until it existed, and a typo in any of them read as "protocol unknown" with nothing saying so.

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

What the candidate answersWhat adopt does
a shep-protocol below the floor Refuses, before shep.toml is touched.
a protocol at or above the floor Adopts, and reports the version it gave.
a version and no protocol line Adopts, and says the protocol is unknown.
nothing, or a run that exits non-zero Adopts, protocol unknown, no notice.

The refusal names both numbers and both ways out:

/usr/local/bin/shep-otel: this dog was built for shep protocol 6, and this
shep needs 8 or newer; reinstall the dog without --locked so it builds
against the current shep-core, or run a shep that accepts protocol 6

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, and an adopt runs two probes, so roughly four seconds is the worst case. It is killed either way, so a dog that ignores both flags and runs costs that time and is adopted with an unknown protocol. It can't hang the adopt that's vetting it.

Only a channel ask is written down. A protocol stored at adopt time would be a copy of a number that can change on disk with nothing watching, so shep asks the binary again rather than remembering what it said.

Asking for the shepherd channel

A dog gets no channel unless it asks. One that wants shep trigger <dog> <action>, or to be stopped by a message, adds a line to its answer:

shep-otel 0.2.0
shep-protocol: 11
shep-channel: true

shep adopt records the ask in [daemon] channel_dogs, and shep starts the dog with channel and shutdown_with_message set. Only true asks. shep adopt reads the line again, and a running dog picks up the change at shep disable then shep enable.

main.rs
use shep_client::dogs::{Probe, Stop};

fn main() {
    Probe::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
        .ask_for_channel()
        .answer::<MyDogConfig>();

    let (stop, request) = Stop::new();
    let shepherd = shep_channel::serve();
    shepherd.on_action("rotate", |_params, _name| "rotated".to_string());
    shepherd.on_shutdown(move || request.request());
    // ...the dog's own loop, watching `stop`.
}

Serve it with shep-channel. A stop is the shutdown message with no signal behind it, so a dog that ignores it is killed once kill_timeout runs out.

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 6, and this shep needs 8 or newer; restarting it
brings it back on that binary, unable to connect. Run a shep that accepts
protocol 6, or reinstall the dog against protocol 8, 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.

What the binary answersWhat restart does
a shep-protocol below the floor Warns, then restarts.
a protocol at or above the floor Restarts, silently.
a version and no protocol line Restarts, silently.
nothing, or a run that exits non-zero Restarts, silently.

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.

note

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:

How it was builtWhich protocol
cargo install <crate>, first time shep-core is re-resolved to the newest compatible release, so the protocol is current.
cargo install <crate>, already installed Nothing is built. Cargo prints already installed, use --force to override and exits 0, so the protocol stays exactly as stale as it was.
cargo install <crate> --force Rebuilt and re-resolved, so the protocol is current. This is the one that upgrades a dog after you upgrade shep.
cargo install --locked, and most CI The shipped lockfile is honoured, so the protocol is whatever was pinned on publish day.

<crate> is the package name on crates.io, which is not always what you call the dog. A dog's adopted name, its binary name and its crate name are three separate things: shep-log-rotate installs a binary of the same name that is usually adopted as log-rotate. Cargo wants the crate.

The middle row is the one that catches people, because it looks like it worked. A dog's own version does not change when the shep it was built against does, so the rebuild you need after upgrading shep is exactly the case cargo declines to do, and it says so in one line before exiting successfully. Reach for --force whenever you are reinstalling a dog rather than installing one.

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
speaks protocol 9, accepts 8 and newer
$ shep dog metrics --version
shep-dog 0.1.24
speaks protocol 9, accepts 8 and newer

Neither prints the shep-protocol: line an external dog's probe answers with, 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 clear the protocol floor 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. Speaking a protocol shep accepts 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.

Answering --schema

shep adopt asks a second question straight after the first: --schema, answered on stdout with a JSON Schema for the dog's own [<name>] section. Same shape as --version and the same budget: spawn, read, kill.

Asked of the binary rather than over the socket, because the dog most in need of configuring is the one that's disabled or has never started, and it has no connection to answer on. Configure then enable is the order an operator wants.

probe answers this flag too, from its type parameter. Put #[dog_config] above the derives on the type the dog deserializes its config into, and mark every field holding a credential:

config.rs
use shep_client::dogs::dog_config;
use shep_client::shep_core::values::{MemSize, UpDuration};

#[dog_config]
#[derive(Default, serde::Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields, default)]
struct MyDogConfig {
    /// Where to POST. Doc comments become the schema's descriptions.
    #[shep(secret)]
    webhook: String,
    /// What to watch.
    path: String,
    /// How much to buffer before posting: `4M`, `512K`, or a byte count.
    batch: Option<MemSize>,
    /// How long to wait between posts: `30s`, `500ms`, `2h`.
    every: Option<UpDuration>,
}

#[shep(secret)] says the field is a bearer credential, and shep renders one as <set>: replaceable, never read back. The attribute exists rather than the schemars extension it writes because that extension key is a string the author types, and a transposed letter in it compiles, validates, marks nothing, and paints the credential on screen.

careful
Above the derives, not below. rustc expands whatever sits above #[dog_config] before reaching it, so a JsonSchema derive listed first has already built its impl by the time the mark goes on the field. That's a compile error naming the fix, not a field that quietly ships unmarked.

The mark travels with the field. A #[serde(rename)] carries it to the renamed property, and a credential inside a nested struct or a map's values is marked in every config that holds that type. Mark the field holding the credential, at whatever depth it sits. Bark marks its sinks map as well as each sink's URL, which redacts the collapsed map a pane shows before anyone opens it.

MemSize and UpDuration need no dependency of their own. A dog configuring a size or a timer reads them out of shep_client::shep_core::values, and shep-client's schema feature turns on the shep-core feature those two JsonSchema impls sit behind. Neither type has a Default, so a field holding one is an Option or carries #[serde(default = "...")].

A schema's own bounds are checked in the editor rather than after the write: a pattern, a minimum or a maximum refuses the value there and says what the field does take. Another reason to publish the narrow thing over a bare string, since shep writes the section without reading it.

note
Answering is optional, exactly as --version is. A dog that says nothing is adopted, recorded as having no schema, and refused nothing, which is every dog written before this contract. What it gives up is the pane below: with no schema to render a form from, its section stays a file an operator hand-edits.

What answering buys is a config pane. e on the dog's row in shep lookout, whether that row sits in the dashboard's own Dogs section or in the settings screen's dogs table, opens a form over its [<name>] section: one row per property, the value the section sets beside it, (unset) where it sets none, and <set> where a field is marked as a credential. A property the schema gives a default still reads (unset): the pane shows the file, not the dog's own fallback. It writes too, behind --allow-control, and it writes the section whole, so the comments in dogs.toml survive an edit shep made. The lookout page has the keys and the rest of the rules.

The schema is asked for again when the pane opens, not cached from when the dog was adopted, so the dog does not have to be running. Configure then enable is the order an operator wants, and it works.

careful
Asking runs it. Opening the pane on an adopted dog spawns that binary with --schema, waits a second for an answer, then kills it, on the same terms shep adopt asks on. That is a third party's code executing because somebody pressed a key, so it is worth knowing before browsing the config of a dog you did not write. A built-in dog is shep's own binary and is never spawned.

A dog that answers something that isn't JSON gets one line, and is adopted anyway:

notice[dog_schema_unreadable]: pydog answered `--schema` with something that
is not JSON, so shep has no description of its settings and they stay a
hand-edited section. The dog is adopted and runs normally; this is a bug to
report to whoever wrote it

Silence earns no such line, for the reason an unstated protocol earns none: it's the ordinary case, and a notice for every dog is how an operator learns to skip the one that matters.

The answer isn't written down, on the same rule the protocol number follows. cargo install replaces a binary with nothing watching, and a stale schema is worse than a stale version number, because it mislabels which field is a credential. Shep asks the binary again when it needs one.

Reading a sheep's own table

A dog that acts per sheep asks Request::DogSheepSettings { dog } for its own name and parses each sheep's table with shep_client::dogs::parse_sheep_settings, one at a time, so a table that doesn't fit never hides the rest. shep stores every table and reads none of them itself. Subscribe to config.sheep.<dog> to hear one change.

Publishing a per-sheep schema

A dog whose per-sheep table deserves a form in shep lookout calls probe_with_sheep::<T, S>(name, version) instead of probe. It answers --schema the same way, with one more key: x-shep-sheep, holding S's schema in the same $defs as T's. A dog with one config type passes it for both.

#[shep(secret)] marks a field of S the way it marks one of T: <set> in lookout, never the value. A nested struct in S becomes dotted rows there, models.worker.model for a models field holding a worker field holding a model. An array, or a map with more than a fixed set of keys, stays one read-only row: neither has a shape the pane can write back into.

Without the schema feature, probe_with_sheep::<T, S> behaves like probe::<T> alone.