Command Bots
A bot is a process you run that ConfigHub can ask to do things, and that reacts to what happens in ConfigHub. It connects outbound to a Channel, verifies that the Channel declares the commands it handles, and from then on receives each command someone runs on that Channel as a CommandRun, executes it, and reports the outcome. Events the Channel is subscribed to arrive over the same connection.
Nothing in ConfigHub reaches into your infrastructure. Whatever credentials the work needs stay with the bot, which polls ConfigHub over HTTP long-polling on the normal API port and works from behind a firewall. The mailbox is in ConfigHub: what the bot was asked, what it answered, and what it is subscribed to, all visible with cub whether or not the process is up.
A bot is the right shape for work that is told to happen, by a person, a CI job or another system, with an outcome to read back: deploy a preview of a pull request, run a migration, sync an Argo CD application, provision something. An event consumer is the same mechanism with only the event half; a bot that declares no commands is one.
This page uses the Go SDK, github.com/confighub/sdk/core, whose channel package is the listener. hellobot is this page's quick start as a repository: one process, two commands and an event log line, about 150 lines, to read and copy. The protocol under it is plain HTTP, described below, so a bot can be written in anything.
Quick start
1. Create the identity
A bot authenticates as a ServiceAccount with a key. Create the account, generate a key, and keep the private half for the bot:
cub serviceaccount create preview-bot
cub serviceaccount key add preview-bot --generate preview-bot
The private key is at ~/.confighub/keys/preview-bot.jwk. The account has no permissions yet; it gets them below.
2. Create the Channel
A Channel lives in a Space. Its Commands field lists the commands the bot handles, by name; write them as JSON and create the Channel with them, granting the account Claim, the permission to be its reader:
cat > commands.json <<'EOF'
{
"preview.deploy": {
"Description": "Deploy or update the preview of a pull request.",
"Options": [
{"Name": "pr", "DataType": "int", "Required": true},
{"Name": "sha", "DataType": "string"}
]
},
"preview.delete": {
"Options": [{"Name": "pr", "DataType": "int", "Required": true}]
}
}
EOF
cub channel create --space platform preview-bot --commands commands.json --permission Claim:serviceaccount:preview-bot
cub command list --space platform
The last command lists preview.deploy and preview.delete, declared by preview-bot, before any process has connected. That is the contract: whoever manages the Channel decides what it offers, and a bot that connects claiming to handle something else is refused.
3. Write the bot
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/confighub/sdk/core/channel"
"github.com/confighub/sdk/core/cubapi"
)
func main() {
server := "https://hub.confighub.com"
// The bot's key, as the JWK that cub serviceaccount key add --generate wrote.
signer, err := cubapi.NewAssertionSigner([]byte(os.Getenv("CONFIGHUB_AUTH_PRIVATE_KEY")), "", "")
if err != nil {
log.Fatal(err)
}
// A client that signs in with the key and renews its session for as long as it runs.
client, err := cubapi.NewClient(cubapi.ClientOptions{
ServerURL: server,
Authenticator: cubapi.AssertionAuthenticator(server, signer),
UserAgent: "preview-bot",
})
if err != nil {
log.Fatal(err)
}
listener := channel.NewListener(client, cubapi.ParseRef("platform/preview-bot"), channel.Options{Concurrency: 4}).
Handle("preview.deploy", func(ctx context.Context, run *channel.Run) (json.RawMessage, error) {
pr, sha := run.Int("pr"), run.String("sha")
url, err := deployPreview(ctx, pr, sha) // your work
if err != nil {
return nil, err // the run is Failed, with this message
}
return json.Marshal(map[string]any{"url": url})
}).
Handle("preview.delete", func(ctx context.Context, run *channel.Run) (json.RawMessage, error) {
if !previewExists(run.Int("pr")) {
return nil, channel.Reject("no preview for PR %d", run.Int("pr")) // Rejected, not Failed
}
return nil, deletePreview(ctx, run.Int("pr"))
})
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := listener.Run(ctx); err != nil && ctx.Err() == nil {
log.Fatal(err)
}
fmt.Println("stopped")
}
A handler gets the run, reads its options with String, Int, Bool, Has or Param into a struct, and returns a result or an error. A result is any JSON; it becomes the run's Result. An error makes the run Failed with the error's text; channel.Reject makes it Rejected, which says the run was not this bot's to do rather than that it tried and could not. A panic is recovered and reported as Failed. The handler's ctx ends when the run's answer is overdue or when the listener is shutting down.
Run is the whole loop: resolving the Channel, acquiring the lease and re-acquiring it with backoff, verifying the commands, polling, running handlers up to Concurrency at a time, reporting outcomes with retries, and on shutdown taking no more runs, waiting for the ones in flight up to DrainTimeout, reporting them, and releasing the lease so a replacement takes over without waiting for the lease to expire.
4. Run it and send a command
CONFIGHUB_AUTH_PRIVATE_KEY="$(cat ~/.confighub/keys/preview-bot.jwk)" go run .
In another terminal, as yourself:
cub command run preview.deploy pr=42 sha=0123abcd
cub command run creates the run, waits for its outcome, prints it, and exits non-zero if the outcome is anything but Completed. cub channel get platform/preview-bot shows the lease held and when the bot last polled; cub commandrun list --space platform is the record of every run.
Commands
Resolution. A caller names a command, not a Channel. cub command run preview.deploy finds the one Channel declaring it, across the Organization or within --space. Two Channels declaring the same command in that scope is an ambiguity the caller resolves with --channel, which is also how one bot per environment works: the same argocd.sync declared by a Channel in each cluster's Space, and --space picks the cluster.
Options. Typed by the declaration. pr=42 is parsed as an int because the signature declares it one; an entity option, unit=platform/web, is resolved from a slug to an ID; --params '{"pr": 42}' supplies a JSON object instead. A required option that is missing is refused before the run is created.
Deadlines. A run waits for the bot to take it until its deadline, five minutes by default and at most an hour, then expires; runs for a bot that is down wait until then. Once taken, the bot has thirty seconds to answer, so a handler that needs longer starts the work and returns what identifies it. A bot built on a workflow engine does exactly that: the run is Completed the moment the workflow is signalled, and progress lives where the work does.
Idempotency. A run delivered to a bot that then loses its lease is delivered again to the next holder. Write handlers so that doing the same thing twice is harmless.
Events
A Channel can subscribe to the event log, and the bot receives the matching events over the same poll, in order, through OnEvent:
cat > subscriptions.json <<'EOF'
[{"EventTypes": ["release.published"], "TargetID": "<target id>"}]
EOF
cub channel update platform/preview-bot --subscriptions subscriptions.json
// goclientnew is "github.com/confighub/sdk/core/openapi/goclient-new", the generated API types.
listener.OnEvent(func(ctx context.Context, event *goclientnew.EventLog) {
log.Printf("%s in space %s", event.EventType, event.SpaceID)
})
Events are handled on the poll goroutine in delivery order, so a slow handler delays the next poll, which is the intended backpressure. The event types today are release.published and changeorder.promoted.
A subscription narrows the log, and the bot's own permissions bound it: an event is delivered only if the bot's ServiceAccount may view the Space it is about, or holds ViewChildren on the Target it is about. So an unscoped subscription plus ViewChildren on a few Targets delivers exactly those Targets' events, and follows the grants as they change.
Delivery is best effort. The Channel's bookmark is advanced as events are handed over, so an event is delivered at most once, and a bot can miss one: a lost response, a process that dies mid-batch, a server that failed between a change and its event, or a bot that was down longer than the log's retention, 48 hours by default. Nothing marks the gap. An event is a nudge to act now, derived from a durable fact; the fact is the source of truth. So two things are required of a bot in return: reactions that are idempotent, and reconciliation from state, on start and on a schedule, so that a missed event costs latency and never correctness. A bot that needs every event is building on the wrong thing; it needs the entity, polled or listed.
Permissions
| Who | Needs |
|---|---|
| The bot's ServiceAccount | Claim on the Channel, to be its reader. Plus whatever its handlers do: a bot that publishes Releases needs the grants a person doing that would need, on the Spaces and Targets it touches. |
| Someone running a command | Use on the Channel, and on the Channel's Space CreateChildren to create the run and ViewChildren to read it back. |
| Someone managing the Channel | Edit to change the commands, subscriptions and the bookmark; Manage to change Permissions. Neither includes Claim, so administering a Channel does not make anyone its reader. |
A bot acts under its own account. What it does shows in revisions and the event log as the bot's, and it can do no more than its account is granted, whoever sent the command.
Operating a bot
- One reader per Channel. A second process that connects while the lease is held waits and polls for it, which is how a rolling restart hands over; a Deployment with one replica and
maxSurge1 starts the replacement before stopping the old one, and the old one releases the lease on shutdown. - Liveness.
cub channel getshowsCondition,Last Seenand the address of the holder. In the process,listener.Status()reports whether the lease is held, when it last polled and worked, and how many runs are in flight, which is what a readiness probe should serve. - Replay.
cub channel update <channel> --event-position <n>moves the bookmark back to deliver events again, or forward to skip. A new Channel starts at the log's tail. - Cancel.
cub commandrun cancel <id>cancels a run still waiting to be taken. A run already delivered runs to its outcome.
The protocol
The listener is a thin client over four calls, all under the bot's normal bearer token:
POST /api/space/{space}/channel/{channel}/leaseacquires the lease and returns a claim token;409while another holder has it.DELETE .../lease?claim_token=releases it.POST .../pollwith{"ClaimToken": ..., "TimeoutSeconds": 30, "MaxRuns": n}waits up to the timeout and returns a list of messages, each aCommandRunor anEvent, or204when nothing arrived; every poll renews the lease.409means the lease is no longer this token's, and the client acquires it again.POST .../command_run/{id}/resultwith{"Status": "Completed"|"Failed"|"Rejected", "Result": ..., "ErrorMessage": ...}reports a run's outcome.
The API reference has the shapes. A bot in another language implements the same loop: acquire, poll, handle, report, release.
See also
- hellobot: the smallest complete bot, this page's quick start runnable.
- Channel and CommandRun: the entities.
- Service accounts: the identity a bot authenticates as.
- Event consumers: the worker tier's event-only client, which this supersedes.
cub channel,cub commandandcub commandrunin the CLI reference.