Document Hub and let docs pages nest (#2864)

* feat(website): nest docs pages under landable groups

The docs sidebar could already render groups from subdirectories in
public-docs, but nothing used it because every doc was a flat file, and
two things stopped it being usable.

A group had no page of its own, so its label was only a toggle and its
name came from the directory name via formatLabel. A directory's
index.md now supplies the group's label, order, and href, which makes
the group landable and lets the label be authored rather than derived.

Categories and top-level directories named the same thing, so a
categorised doc at hub/index.md produced a group nested inside its own
category. Only directories below the top level become groups now.

Also fixes two behaviours in the sidebar:

- The open/closed effect assigned containsActive on every change, so a
  group you expanded to browse collapsed the moment you navigated
  anywhere else. Navigating in now opens a group and navigating away
  leaves it alone.
- The sidebar scrolls independently of the page and never scrolled the
  active entry into view, so loading or refreshing a page far down the
  list left the sidebar parked at the top with no visible position.

The group row is a single target: it navigates and expands, and
collapses when clicked again from the page it points at. A separate
chevron button would put two actions a few pixels apart.

* docs(hub): document Hub

Adds a Hub section covering what it is, how it works, daemons,
triggers, configuration, activity, and the two ways to run it.

The section separates using Hub from running it. Overview, quickstart,
daemons, triggers, and configuration hold regardless of who operates
the deployment; the self-hosting branch owns deploying the service and
creating the GitHub, Slack, and Discord apps it talks to. That split
falls on a real seam, because those apps are exactly what a hosted Hub
would own on your behalf. Hosted is a placeholder until it ships.

Each provider gets a trigger page carrying its events, filter
semantics, and the full paseo.event.* reference for the data a prompt
can read. That data is the thing a config author actually needs and
the thing they cannot get from anywhere else.

Every YAML block was parsed against the Hub config schema rather than
eyeballed, which kept several plausible-looking things out of the
docs: worktree prNumber takes a literal number and not a merge
variable, only daemon environments run today, and github.push has no
actor so it can never satisfy the required from_users filter.

Troubleshooting moves to order 90 so it stays last.

* refactor(website): let the active docs entry scroll itself into view

The layout queried the sidebar for the active entry after every
navigation, which coupled it to when that entry mounts. A link inside a
collapsed group only mounts once the group's own effect opens it, so
the query could run against a DOM that does not contain it yet.

Moving the scroll to the entry removes the ordering question rather
than answering it, and drops the ref and the cross-component query.

Reported by Greptile on #2864. The failure it described does not
reproduce: navigating from a page outside a collapsed group into one of
its children still scrolls correctly, verified with the group confirmed
collapsed and the sidebar forced to the top. The coupling was real even
though the bug was not.

* docs(hub): stop listing BETTER_AUTH_URL as required

It falls back to PASEO_CLOUD_PUBLIC_URL, so a deployment that sets the
public origin already has a correct auth base URL. Listing it as
required made the Fly recipe look incomplete when it was not.

Moved to optional with its default, and recorded what actually breaks
without BETTER_AUTH_SECRET: the auth routes close and nobody can sign
in.

Reported by Codex on #2864, which read the same inconsistency the other
way round and proposed adding the variable to the Fly example.

* docs(hub): attach a database in the Fly recipe

The Fly section showed inline config and a secrets command, neither of
which supplied DATABASE_URL. It defaults to localhost:5432, which on
Fly reaches nothing, so the documented deployment could not start.

fly postgres attach sets DATABASE_URL as a secret, so the step is the
attach itself rather than another variable to copy.

Reported by Codex on #2864.

* docs: align Hub self-hosting contract
This commit is contained in:
Mohamed Boudra
2026-08-05 03:58:27 +08:00
committed by GitHub
parent d7f72d2805
commit dfd18a44a7
26 changed files with 1670 additions and 22 deletions
+72 -17
View File
@@ -1,6 +1,6 @@
import { Link, useLocation } from "@tanstack/react-router";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type DocsNavNode } from "~/docs";
interface DocsNavProps {
@@ -13,6 +13,7 @@ const ACTIVE_OPTIONS_EXACT = { exact: true };
function nodeContainsHref(node: DocsNavNode, href: string): boolean {
if (node.type === "page") return node.href === href;
if (node.type === "group" && node.href === href) return true;
return node.children.some((child) => nodeContainsHref(child, href));
}
@@ -20,6 +21,22 @@ function clsx(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(" ");
}
/**
* The sidebar scrolls independently of the page, so the active entry has to
* bring itself into view. The entry owns this rather than the layout, because
* a link inside a collapsed group only mounts once that group opens, and
* anything watching from above would have to query before that happens.
*/
function useScrollIntoViewWhenActive(isActive: boolean) {
const ref = useRef<HTMLAnchorElement>(null);
useEffect(() => {
if (isActive) ref.current?.scrollIntoView({ block: "nearest" });
}, [isActive]);
return ref;
}
function PageLink({
node,
mobile,
@@ -31,12 +48,15 @@ function PageLink({
}) {
const location = useLocation();
const isActive = location.pathname === node.href;
const ref = useScrollIntoViewWhenActive(isActive);
return (
<Link
ref={ref}
to={node.href}
activeOptions={ACTIVE_OPTIONS_EXACT}
onClick={onNavigate}
data-docs-nav-active={isActive ? "" : undefined}
className={clsx(
"block px-3 py-2 text-sm rounded-md transition-colors",
mobile
@@ -62,30 +82,65 @@ function GroupNode({
const location = useLocation();
const currentHref = location.pathname;
const containsActive = useMemo(() => nodeContainsHref(node, currentHref), [node, currentHref]);
const isActive = node.href === currentHref;
const ref = useScrollIntoViewWhenActive(isActive);
const [isOpen, setIsOpen] = useState(containsActive);
const toggle = useCallback(() => setIsOpen((open) => !open), []);
// Navigating into a group opens it. Navigating away must not close it, or a
// group you opened to browse collapses the moment you click something else.
useEffect(() => {
setIsOpen(containsActive);
if (containsActive) setIsOpen(true);
}, [containsActive]);
// The row is one target: it takes you to the group's page and opens it.
// Clicking it again once you are already there collapses it.
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
if (isActive) {
event.preventDefault();
toggle();
return;
}
setIsOpen(true);
onNavigate?.();
},
[isActive, onNavigate, toggle],
);
const hasChildren = node.children.length > 0;
const chevron = isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
const rowClassName = clsx(
"w-full flex items-center justify-between gap-2 px-3 py-2 text-sm rounded-md transition-colors text-left",
mobile
? "text-muted-foreground hover:text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
containsActive && "text-foreground",
isActive && !mobile && "bg-muted",
);
return (
<div>
<button
type="button"
onClick={toggle}
className={clsx(
"w-full flex items-center justify-between gap-2 px-3 py-2 text-sm rounded-md transition-colors",
mobile
? "text-muted-foreground hover:text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
containsActive && "text-foreground",
)}
>
<span>{node.label}</span>
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{isOpen && (
{node.href === undefined ? (
<button type="button" onClick={toggle} aria-expanded={isOpen} className={rowClassName}>
<span>{node.label}</span>
{hasChildren && chevron}
</button>
) : (
<Link
ref={ref}
to={node.href}
activeOptions={ACTIVE_OPTIONS_EXACT}
onClick={handleClick}
aria-expanded={hasChildren ? isOpen : undefined}
data-docs-nav-active={isActive ? "" : undefined}
className={rowClassName}
>
<span>{node.label}</span>
{hasChildren && chevron}
</Link>
)}
{isOpen && hasChildren && (
<div className="ml-3 pl-3 border-l border-border space-y-0.5">
<NavTree nodes={node.children} mobile={mobile} onNavigate={onNavigate} />
</div>
+24 -4
View File
@@ -36,6 +36,8 @@ export type DocsNavNode =
label: string;
children: DocsNavNode[];
order: number;
/** Set when the directory has an `index.md`, which makes the group landable. */
href?: string;
}
| {
type: "page";
@@ -159,13 +161,18 @@ function formatLabel(segment: string): string {
return segment.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function insertDocByPath(nodes: DocsNavNode[], doc: Doc): void {
function insertDocByPath(nodes: DocsNavNode[], doc: Doc, stripTopDirectory = false): void {
const relative = doc.sourcePath.replace(/^public-docs\//, "");
const segments = relative.replace(/\.md$/, "").split("/");
const fileName = segments.pop() ?? "";
const directories = segments;
// A section's top-level directory and its category name the same thing, so
// only directories below it become collapsible groups.
if (stripTopDirectory) directories.shift();
let current = nodes;
let parent: Extract<DocsNavNode, { type: "group" }> | undefined;
for (const segment of directories) {
let group = current.find(
(node): node is Extract<DocsNavNode, { type: "group" }> =>
@@ -183,13 +190,22 @@ function insertDocByPath(nodes: DocsNavNode[], doc: Doc): void {
current.push(group);
}
parent = group;
current = group.children;
}
const pageSegment = fileName === "index" ? (directories.at(-1) ?? "") : fileName;
// A directory's index.md describes its own group: it supplies the label and
// order the directory name can't, and makes the group itself navigable.
if (fileName === "index" && parent !== undefined) {
parent.label = doc.frontmatter.nav;
parent.order = doc.frontmatter.order;
parent.href = doc.href;
return;
}
current.push({
type: "page",
segment: pageSegment,
segment: fileName,
label: doc.frontmatter.nav,
href: doc.href,
order: doc.frontmatter.order,
@@ -198,6 +214,7 @@ function insertDocByPath(nodes: DocsNavNode[], doc: Doc): void {
function nodeOrder(node: DocsNavNode): number {
if (node.type === "page") return node.order;
if (node.type === "group" && node.href !== undefined) return node.order;
if (node.children.length === 0) return Infinity;
let min = Infinity;
for (const child of node.children) {
@@ -240,7 +257,7 @@ export function buildDocsNavTree(docs: Doc[]): DocsNavNode[] {
for (const [label, docsInCategory] of byCategory) {
const children: DocsNavNode[] = [];
for (const doc of docsInCategory) {
insertDocByPath(children, doc);
insertDocByPath(children, doc, true);
}
root.push({
type: "category",
@@ -264,6 +281,9 @@ function findNodePath(
return [...path, node];
}
if (node.type !== "page") {
if (node.type === "group" && node.href === href) {
return [...path, node];
}
const found = findNodePath(node.children, href, [...path, node]);
if (found) return found;
}
+10
View File
@@ -193,6 +193,16 @@ paseo daemon stop # Stop the daemon
Use `PASEO_HOME` to run multiple isolated daemon instances.
## Hub
```bash
paseo hub connect <url> # Enroll this daemon with a Paseo Hub
paseo hub status # Show the current Hub relationship
paseo hub disconnect # End it
```
See [Daemons in Hub](/docs/hub/daemons).
## Connecting to a remote daemon
`--host` accepts either a local target (`host:port`, a unix socket, or a Windows pipe) or a pairing offer URL, the same `https://app.paseo.sh/#offer=...` link the mobile app uses for QR pairing. With an offer URL the CLI connects through the Paseo relay with end-to-end encryption, so you can drive a daemon on another machine without exposing it to the network.
+2
View File
@@ -10,6 +10,8 @@ category: Getting started
Your Paseo app connects to the daemon running on your computer or server. You can connect through the Paseo relay or directly with Tailscale.
This is client-to-daemon transport. If you are looking for the service that starts agents from GitHub, Slack, and Discord events, that is [Hub](/docs/hub).
- [Paseo relay](#paseo-relay)
- [Tailscale](#tailscale)
+58
View File
@@ -0,0 +1,58 @@
---
title: Hub activity
description: Read what Hub did with an event, tell a filtered event from an unrouted one, and debug a trigger that did nothing.
nav: Activity
order: 71
category: Hub
---
# Hub activity
Every event Hub accepts is recorded, whether or not it ran anything. That record is how you debug a trigger.
## Where to look
**Project → Activity** lists the events routed to that project: the event, its source, the result, and when it arrived.
The result column says what happened:
| Result | Meaning |
| --------------------- | ----------------------------------------------------------------- |
| A trigger name | The event matched that trigger and dispatched |
| A lifecycle state | The execution is in progress or finished |
| `no_matching_trigger` | The event reached the project, but no trigger's filters matched |
| `provider_unrouted` | No project route existed for the resource that produced the event |
**Project → Executions** lists the runs: which trigger, which configuration revision, which daemon, duration, and outcome.
**Connections → Known unrouted events** is the organization-level view. It holds events whose credential belongs to your organization but which no project's configuration claimed. A repository you connected but never named in a trigger lands here.
Those two are different problems. `no_matching_trigger` means your filters are wrong. An unrouted event means no configuration mentions that repository, workspace, or guild at all.
## Nothing happened when I mentioned the bot
Work down this list.
1. **Is the event in the project's Activity?** If not, check **Connections → Known unrouted events**. If it is there, no trigger names that resource. Add `filters.repo`, `filters.workspace`, or `filters.guild` and push.
2. **Is the event anywhere at all?** If not, the event never reached Hub. Check the provider's own delivery log: GitHub's App → Advanced → Recent Deliveries, or Slack's Event Subscriptions page. Then check that the app is subscribed to that event type.
3. **Is your user in `from_users`?** This is the most common cause. GitHub uses your login; Slack and Discord use the user ID, not the display name.
4. **Did the mention match?** On GitHub, `contains` must appear in the comment body. On Slack and Discord the bot must actually be mentioned, and `pattern` matches only at the start of the text after the mention.
5. **Is the configuration you think is active actually active?** The Configuration tab shows the active revision and the last sync attempt. A failed push leaves the old revision serving.
6. **Is the daemon connected?** An offline daemon fails dispatch with `daemon_not_connected`.
7. **Did it run and stop early?** Check the execution. An agent that never reports back is ended by `idle_timeout` after 5 minutes by default, or `timeout` after an hour.
## Sync failures
The Configuration tab shows the latest sync state:
| State | What to do |
| ----------------------- | ------------------------------------------------------------------------ |
| Fetch failed | The file is missing at that commit, or the App can't read the repository |
| Invalid | Validation failed, or the config names something unreachable |
| Superseded push ignored | A newer commit already moved the branch head; nothing is wrong |
"Names something unreachable" is usually a repository the installation doesn't cover, a daemon that was renamed, or a connection slug that no longer exists. See [How Hub works](/docs/hub/concepts).
## Nothing is retried
Hub does not queue events. A dispatch that fails because the daemon was offline stays failed, so trigger it again once the daemon is back.
+79
View File
@@ -0,0 +1,79 @@
---
title: How Hub works
description: The Hub object model, how an inbound event reaches a project, and what happens when a configuration is activated.
nav: How it works
order: 62
category: Hub
---
# How Hub works
## The object model
```text
organization
├── members
├── connections GitHub installations, Slack workspaces, Discord guilds
├── daemons registered machines that run agents
└── projects
└── configuration
├── environments
└── triggers
```
**Connections belong to the organization.** One organization can hold several GitHub installations, several Slack workspaces, and several Discord guilds at the same time. Your personal GitHub account and two company organizations can sit side by side.
**Every connection gets a slug**, generated from the account it points at: `getpaseo-github`, `boudra-github`, `acme-slack`. Slugs are unique inside the organization, and configuration uses them to name a specific connection.
**Daemons belong to the organization too.** Register a machine once and any project can use it.
**A project is one set of environments and triggers.** Projects are how you separate work that should stay separate: one product, one team, one repository. They share the organization's connections and daemons, so separating costs you nothing.
**A project has one active configuration.** You edit it in the dashboard or sync it from a repository, and it applies as a whole.
**Nothing is attached to a project in the dashboard.** No screen assigns a repository or a channel. The project's configuration decides what it listens to, through trigger filters.
## Routing
```text
event arrives
→ the connection verifies it (signature, or an authenticated gateway)
→ the event carries a resource id (repository, workspace, guild)
→ Hub finds compiled routes referencing that resource
→ the trigger's filters run
→ the trigger's environment executes
```
Routes are compiled when a configuration is activated, not evaluated per event. A repository is not owned by one project, so if two projects both name it, both run.
## Activation
Activating a configuration resolves what you wrote into stable identities:
| You write | Hub resolves it to |
| ---------------------------- | --------------------------------------- |
| `filters.repo: owner/repo` | a GitHub repository id and a connection |
| `filters.workspace: T0123…` | a Slack workspace and a connection |
| `filters.guild: 9876…` | a Discord guild and a connection |
| `environment.daemon: my-box` | a registered daemon id |
If any of those cannot be resolved through a connection in the organization, activation fails and the previous revision stays active. You find out when you push, rather than when someone comments on an issue.
A trigger with no resource filter routes to every connection of that provider in the organization. Add `filters.connection: <slug>` to narrow it to one.
## Executions
A matched trigger produces an execution. Hub sends the create request to the daemon, then owns that agent's lifecycle: reconnect recovery, output observation, and completion.
- `timeout` is absolute and defaults to `1h`.
- `idle_timeout` starts only when the daemon reports the agent idle, resets on the next idle report, and clears when the agent reports running. It defaults to `5m`.
- Every dispatched agent gets a per-execution MCP server with `finish_execution`. Completion is authoritative when the agent calls it.
- `allow_outputs` adds a single-use `reply` tool for Slack and Discord executions.
If Hub loses the create response, or the daemon reconnects, Hub resends the same create intent with the same execution id. The daemon returns the existing agent instead of running the prompt twice.
## Trust boundary
A project's configuration can reference any connection in its organization. Anyone who can push to the configuration repository controls that project's access to those connections, so protect that branch the way you protect a release branch.
Triggers require a `from_users` allowlist for the same reason. Without one, any stranger commenting on a public issue could start an agent on your machine.
+182
View File
@@ -0,0 +1,182 @@
---
title: Hub examples
description: Copyable hub.yml configurations for issue triage, PR review, Discord and Slack mentions, and isolated worktrees.
nav: Examples
order: 70
category: Hub
---
# Hub examples
Complete configurations you can adapt. Field details are in the [`hub.yml` reference](/docs/hub/configuration/hub-yml).
Every example assumes one environment:
```yaml
environments:
- name: dev
kind: daemon
daemon: my-macbook
cwd: /Users/you/code/api
```
## Answer a mention on an issue
The baseline. An agent runs in your checkout and can push, because GitHub-triggered executions get a scoped `GH_TOKEN`.
```yaml
triggers:
- name: mention
on: github.issue_comment
environment: dev
filters:
repo: acme/api
contains: "@paseo"
from_users: [alice, bob]
agent:
provider: codex
mode: full-access
prompt: |
You were mentioned on ${{ paseo.event.github.trigger_url }}.
${{ paseo.event.github.comment.body }}
Reply by commenting on the thread with gh.
```
## Review a pull request in its own worktree
`checkout-branch` on the PR's head branch puts the agent on the right code without disturbing your working tree.
```yaml
triggers:
- name: review
on: github.pull_request_review_comment
environment: review
filters:
repo: acme/api
contains: "@paseo review"
from_users: [alice]
agent:
provider: claude
mode: full-access
timeout: 30m
auto_archive: true
prompt: |
Address this review comment on PR #${{ paseo.event.github.pull_request.number }}:
${{ paseo.event.github.comment.body }}
```
Add the worktree to the environment it uses:
```yaml
environments:
- name: review
kind: daemon
daemon: my-macbook
cwd: /Users/you/code/api
worktree:
mode: checkout-branch
branch: ${{ paseo.event.github.pull_request.head.ref }}
```
## Triage new issues on a fresh branch
```yaml
environments:
- name: triage
kind: daemon
daemon: my-macbook
cwd: /Users/you/code/api
worktree:
mode: branch-off
newBranch: triage/${{ paseo.event.github.issue.number }}
base: main
triggers:
- name: triage
on: github.issues
environment: triage
filters:
repo: acme/api
from_users: [alice, bob]
agent:
provider: codex
mode: full-access
prompt: |
Investigate issue #${{ paseo.event.github.issue.number }}:
${{ paseo.event.github.issue.title }}
${{ paseo.event.github.issue.body }}
If you find the cause, open a pull request.
```
## Discord mention with repository access
The trigger fires from Discord, so the GitHub token has to be requested by connection slug.
```yaml
triggers:
- name: discord
on: discord.mention
environment: dev
filters:
guild: "123456789012345678"
from_users: ["345678901234567890"]
agent:
provider: codex
mode: full-access
env:
GH_TOKEN: ${{ paseo.connections.acme-github.token }}
allow_outputs:
- type: discord.reply
prompt: |
${{ paseo.event.discord.trigger_message.body }}
Reply in the thread when you are done.
```
## Slack mention that replies in-thread
```yaml
triggers:
- name: slack
on: slack.mention
environment: dev
filters:
workspace: T01234567
channels: [C01234567]
from_users: [U01234567]
agent:
provider: codex
mode: full-access
idle_timeout: 10m
allow_outputs:
- type: slack.reply
prompt: ${{ paseo.event.slack.trigger_message.body }}
```
## One trigger, several repositories
Omit `repo` and the trigger listens to every repository the GitHub connection can see. Pin the connection when the organization has more than one.
```yaml
triggers:
- name: any-repo
on: github.issue_comment
environment: dev
filters:
connection: acme-github
contains: "@paseo"
from_users: [alice]
agent:
provider: codex
mode: full-access
prompt: |
${{ paseo.event.github.repository_full_name }}:
${{ paseo.event.github.comment.body }}
```
`cwd` is fixed per environment, so a trigger like this makes sense when the agent works from a token rather than a checkout.
+150
View File
@@ -0,0 +1,150 @@
---
title: hub.yml reference
description: Every field in a Paseo Hub configuration: environments, triggers, filters, outputs, and merge variables.
nav: hub.yml reference
order: 69
category: Hub
---
# `hub.yml` reference
The complete field reference for a Hub configuration. For where the file lives and how it activates, see [Configuration](/docs/hub/configuration).
```text
environments: where agents run
triggers: what starts them
```
Both keys are required. `environments` needs at least one entry.
## Environments
An environment says where an agent runs.
```yaml
environments:
- name: dev
kind: daemon
daemon: my-macbook
cwd: /Users/you/code/your-repo
```
| Field | Required | Notes |
| ---------- | -------- | ----------------------------------------------------------- |
| `name` | yes | Referenced by a trigger's `environment`. |
| `kind` | yes | `daemon`. Only daemon environments run today. |
| `daemon` | yes | The daemon's display name, resolved to an id on activation. |
| `cwd` | yes | Absolute path on that machine. |
| `worktree` | no | Run in an isolated git worktree instead of `cwd` directly. |
### Worktrees
```yaml
worktree:
mode: branch-off
newBranch: hub/issue-${{ paseo.event.github.issue.number }}
base: main
```
Three modes:
| Mode | Fields | Behavior |
| ----------------- | ---------------------------- | ------------------------------------------ |
| `branch-off` | `newBranch`, optional `base` | New branch off `base`, or the current head |
| `checkout-branch` | `branch` | Existing branch |
| `checkout-pr` | `prNumber` | The pull request's head |
`newBranch`, `base`, and `branch` accept merge variables. `prNumber` does not, because it is a literal number. For a per-event pull request use `checkout-branch` with `${{ paseo.event.github.pull_request.head.ref }}`.
See [Git worktrees](/docs/worktrees) for how the daemon sets them up.
## Triggers
```yaml
triggers:
- name: review
on: github.pull_request_review_comment
environment: dev
filters:
repo: acme/api
contains: "@paseo"
from_users: [alice, bob]
agent:
provider: codex
mode: full-access
prompt: ${{ paseo.event.github.comment.body }}
timeout: 45m
idle_timeout: 5m
auto_archive: true
```
| Field | Required | Default | Notes |
| --------------- | -------- | ------- | --------------------------------------------------------- |
| `name` | yes | | Unique within the configuration. |
| `on` | yes | | `provider.event`, see below. |
| `environment` | yes | | An environment `name`. |
| `agent` | yes | | `provider`, `mode`, optional `model`, `thinkingOptionId`. |
| `prompt` | yes | | Supports merge variables. |
| `filters` | yes | | `from_users` is mandatory. See below. |
| `env` | no | | Environment variables for the agent process. |
| `allow_outputs` | no | none | Lets the agent reply to the source. |
| `timeout` | no | `1h` | Absolute wall clock. Max `24h`. |
| `idle_timeout` | no | `5m` | Starts on the first idle report from the daemon. |
| `auto_archive` | no | `false` | Archive the agent when the execution ends. |
Durations are a positive integer plus `ms`, `s`, `m`, or `h`.
`agent.provider` and `agent.mode` are the same identifiers the Paseo CLI uses, such as `codex`, `claude`, and `full-access`. See [Supported providers](/docs/supported-providers).
## Events and filters
`on` and `filters` decide which events reach a trigger. Both are documented in [Triggers](/docs/hub/triggers), with a page per provider for the events and data each one exposes.
`filters` is required, and `from_users` must be non-empty. Validation rejects a trigger without it.
## Merge variables
`${{ ... }}` expressions are available in `prompt`, `env`, and worktree branch fields. Values reach the agent's process only through `env`; nothing else is injected.
### Event data
`${{ paseo.event.<provider>.<path> }}` exposes the provider's own payload, unflattened. The full field list per provider lives with its trigger page: [GitHub](/docs/hub/triggers/github), [Slack](/docs/hub/triggers/slack), [Discord](/docs/hub/triggers/discord).
Manual runs expose `actor`, `input`, `config`, `trigger`, and `delivery_id` under `paseo.event.manual`.
### Connection credentials
`${{ paseo.connections.<slug>.token }}` mints a token from a named connection. GitHub connections are the only ones that provide a token today.
Use it when a trigger needs a provider other than the one that fired it:
```yaml
triggers:
- name: discord-fix
on: discord.mention
environment: dev
filters:
guild: "123456789"
from_users: ["987654321"]
agent:
provider: codex
mode: full-access
env:
GH_TOKEN: ${{ paseo.connections.acme-github.token }}
prompt: ${{ paseo.event.discord.trigger_message.body }}
```
You must name the connection. An organization can hold several GitHub installations and Hub will not guess between them.
GitHub-triggered executions already receive a scoped `GH_TOKEN` for the triggering repository. You do not need to map one.
## Outputs
```yaml
allow_outputs:
- type: slack.reply
```
`slack.reply` and `discord.reply` give the agent a `reply` tool for the conversation that triggered it. The reply is plain text, posted in-thread, and can be sent once per execution.
Every execution also gets `finish_execution`, whether or not `allow_outputs` is set.
+59
View File
@@ -0,0 +1,59 @@
---
title: Hub configuration
description: Where a project's configuration comes from, how GitHub sync works, and how revisions activate and roll back.
nav: Configuration
order: 68
category: Hub
---
# Hub configuration
A project is configured by one versioned document. The project's **Configuration** tab shows the active revision, its source, and the last synchronization attempt.
## Sources
A configuration comes from exactly one source:
- **GitHub source**: one repository, the file `.paseo/hub.yml`, on the repository's current default branch.
- **Manual source**: edited in the dashboard and saved with **Save and activate**.
Pick a GitHub source by choosing a repository and clicking **Use for configuration**. That syncs immediately and enables automatic deployment.
The path and the branch are fixed. There is no setting for either.
## Sync
A push to the default branch of the configuration repository triggers a sync:
1. Hub fetches `.paseo/hub.yml` at that exact commit.
2. It validates the document and resolves every repository, workspace, guild, and daemon it names.
3. On success the revision becomes active.
**Sync now** does the same on demand.
Every attempt is recorded, including failures. The outcomes you will see:
| Outcome | What happened |
| ----------------------- | ------------------------------------------------------------------------------- |
| Activated | Valid document, everything resolved, now serving events. |
| Invalid | The document failed validation or named something the organization can't reach. |
| Fetch failed | The file is missing, or GitHub could not be read. |
| Superseded push ignored | A newer commit already moved the branch head. |
A failed sync never replaces the active revision. A repository with a broken `hub.yml` keeps serving the last good one.
## Revisions
Revisions are immutable and numbered per project. Rolling back selects an earlier revision and recompiles its routes. The next valid push activates again, so rollback holds only until the next push.
## Switching source
Switching from GitHub to manual copies the active revision into the editor and stops syncing. Switching back means choosing a repository again.
While a project uses a GitHub source, the dashboard editor is read-only. The repository is the source of truth.
## The configuration repository does not have to be the repository you watch
`filters.repo` can name any repository the organization has a connection for. Keeping `hub.yml` in a private repository while triggers watch several public ones is a common setup, because push access to the configuration repository grants access to the organization's connections.
Next: the [`hub.yml` reference](/docs/hub/configuration/hub-yml).
+83
View File
@@ -0,0 +1,83 @@
---
title: Daemons in Hub
description: Enroll a machine with Hub, reference it from configuration, and understand what Hub owns once it is connected.
nav: Daemons
order: 63
category: Hub
---
# Daemons in Hub
A daemon is one of your machines running the Paseo daemon. Enroll it once with your Hub organization, then any project can reference it.
## Connect
On the machine:
```sh
paseo hub connect https://hub.example.com
```
The CLI prints a URL and a verification code, and opens your browser if it can. In Hub, open **Daemons → Register a daemon**, enter the code, name the daemon, and approve it.
The name you choose here is what configuration references. It can be renamed later, but renaming after a configuration is active means updating that configuration.
For unattended setup, skip the browser with an enrollment token:
```sh
paseo hub connect https://hub.example.com --token <token>
```
Check and undo:
```sh
paseo hub status
paseo hub disconnect
paseo hub disconnect --force # drop local authority when Hub is unreachable
```
One daemon has one Hub relationship. Connecting a daemon that already has one is refused.
## Reference it from configuration
```yaml
environments:
- name: dev
kind: daemon
daemon: my-macbook
cwd: /Users/you/code/your-repo
```
`daemon` is the display name. It resolves to a daemon id when the configuration activates, so a daemon that no longer exists fails activation instead of failing at dispatch.
`cwd` is a path on that machine. Hub does not clone anything for you; the directory must already exist.
To keep executions off your working tree, add a worktree:
```yaml
worktree:
mode: branch-off
newBranch: hub/${{ paseo.event.github.issue.number }}
base: main
```
See [Git worktrees](/docs/worktrees) for setup hooks and scripts.
## What Hub owns
For agents it dispatched, Hub owns creation, reconnect recovery, output observation, and completion. Agents you start yourself are untouched.
If Hub loses the create response, or the daemon restarts mid-execution, Hub resends the same create intent with the same execution id. The daemon returns the existing agent rather than running the prompt again. An agent that closed or errored is recorded as interrupted; Hub never silently starts a second one.
## Status
| Status | Meaning |
| ----------------- | ---------------------------------------------- |
| Approval required | The CLI is waiting for you to approve the code |
| Connected | Online and accepting dispatch |
| Offline | Enrolled but not currently connected |
| Revoked | Access removed from Hub |
An event that arrives while a daemon is offline fails dispatch with `daemon_not_connected`. Nothing is queued for later. The event is in the project's Activity, and the trigger has to fire again.
Revoking from **Daemons → Revoke daemon** ends the relationship from Hub's side. The daemon keeps running your local agents.
+51
View File
@@ -0,0 +1,51 @@
---
title: Hub FAQ
description: Common questions about projects, connections, configuration, and daemons in Paseo Hub.
nav: FAQ
order: 77
category: Hub
---
# Hub FAQ
## Do I need Hub to use Paseo?
No. Paseo runs agents on your machines without it. Hub adds what a single daemon cannot do on its own: starting agents from external activity, versioned configuration, a shared record of what ran, and team access.
## Can one organization connect several GitHub organizations?
Yes. Connections belong to the organization and there is no limit on how many you add. Each gets its own slug.
## How should I split my work into projects?
The way you already split it: one per product, one per team, or one per repository. A project owns one set of environments and triggers, so anything that should be configured and deployed together belongs in the same project.
Projects share the organization's connections and daemons, so a new project does not mean connecting GitHub or registering a daemon again.
## Can two projects watch the same repository?
Yes. Both run. Repositories are not owned by a project.
## Can the configuration live somewhere other than the repository being watched?
Yes. `filters.repo` can name any repository the organization can reach, so a private repository can hold `.paseo/hub.yml` for triggers that watch public ones. Push access to that repository grants access to the organization's connections, which is a good reason to keep it private and protected.
## Can I edit configuration in the dashboard?
Yes, with a manual source. A project using a GitHub source is read-only in the dashboard, since the repository is the source of truth. Switching to manual copies the active revision into the editor and stops syncing.
## Who can trigger an agent?
Only the users listed in a trigger's `from_users`. It is required and cannot be empty.
## What happens if the daemon is offline?
Dispatch fails and the event is recorded as failed. Nothing is queued, so trigger it again once the daemon is back.
## Can an agent reply back to Slack or Discord?
Yes, with `allow_outputs`. It gets one plain-text thread reply per execution. On GitHub, agents reply through the scoped `GH_TOKEN` they already have, so `gh issue comment` works.
## Can I use it without GitHub?
Yes. A project with a manual configuration and a Discord or Slack connection works fine. GitHub is only needed if you want configuration synced from a repository.
+13
View File
@@ -0,0 +1,13 @@
---
title: Hosted Hub
description: The managed Paseo Hub, not available yet.
nav: Hosted
order: 72
category: Hub
---
# Hosted Hub
Not available yet. For now, see [self-hosting](/docs/hub/self-hosting).
When it ships, Paseo owns the GitHub App, Slack app, and Discord application, so connecting a provider is one click rather than a console walkthrough. Projects, configuration, triggers, daemons, and activity work the same way on both.
+55
View File
@@ -0,0 +1,55 @@
---
title: Hub
description: The layer above your daemons. Register them, give them capabilities, and share them with your team.
nav: Overview
order: 60
category: Hub
---
# Hub
A daemon runs agents on one machine, for you. Paseo Hub is the layer above your daemons. You register your daemons with it, and it gives them capabilities they do not have on their own.
```text
Hub
┌─────────┼─────────┐
▼ ▼ ▼
laptop devbox build server
```
What that gives you today:
- Agents that start on their own, from activity in GitHub, Slack, and Discord.
- Configuration that lives in a repository and deploys when you push.
- A record of everything that arrived, what it matched, and what ran.
- One place for your team to see all of it.
Your daemons keep running agents where they always did. Hub decides when to ask them to.
## How it fits together
An organization holds your connections to GitHub, Slack, and Discord, and your registered daemons. Projects sit inside it, and each project has its own configuration.
```text
organization
├── connections
├── daemons
└── projects
```
A project is one set of environments and triggers. Split your work into projects the way you already split it in your head: one per product, per team, or per repository. Connections and daemons are shared across all of them, so a new project does not mean connecting GitHub again.
[How Hub works](/docs/hub/concepts) covers this properly.
## Reading order
1. [How it works](/docs/hub/concepts)
2. [Daemons](/docs/hub/daemons)
3. [Triggers](/docs/hub/triggers)
4. [Configuration](/docs/hub/configuration)
[Quickstart](/docs/hub/quickstart) goes end to end if you would rather start by doing.
## Running it
Two ways: [hosted](/docs/hub/hosted) or [self-hosted](/docs/hub/self-hosting). Everything above is the same either way.
+79
View File
@@ -0,0 +1,79 @@
---
title: Hub quickstart
description: Sign in, connect GitHub, register a daemon, and get an agent starting from a mention.
nav: Quickstart
order: 61
category: Hub
---
# Hub quickstart
From an empty Hub to an agent that starts when someone mentions it. Each step links to the page that covers it properly.
## 1. Sign in
Open your Hub and sign in with the owner account created during deployment. Replace its temporary password when prompted. Your connections, daemons, and projects all live inside its organization.
## 2. Connect GitHub
Open **Connections** and connect GitHub. Choose the account or organization whose repositories you want to use.
The connection appears with a generated slug like `yourname-github`.
## 3. Register a daemon
On the machine that will run agents:
```sh
paseo hub connect https://your-hub.example.com
```
The CLI prints a verification code. In Hub, open **Daemons → Register a daemon**, enter the code, and give it a name. See [Daemons](/docs/hub/daemons).
## 4. Create a project
Open **Projects → New project**. On its **Configuration** tab, pick a repository and choose **Use for configuration**. Hub now reads `.paseo/hub.yml` from that repository's default branch.
## 5. Commit the configuration
Add `.paseo/hub.yml` to that repository:
```yaml
environments:
- name: dev
kind: daemon
daemon: my-daemon
cwd: /Users/you/code/your-repo
triggers:
- name: mention
on: github.issue_comment
environment: dev
filters:
repo: yourname/your-repo
contains: "@paseo"
from_users: [your-github-login]
agent:
provider: codex
mode: full-access
prompt: |
Someone asked for help on ${{ paseo.event.github.issue.html_url }}.
${{ paseo.event.github.comment.body }}
```
`daemon` is the name you gave it in step 3. `cwd` is a directory on that machine.
## 6. Push
Push to the default branch. Hub fetches the file at that commit, validates it, and activates it. The **Configuration** tab shows the active revision and the last sync.
If the file is invalid, Hub records the failure and keeps the previous revision active.
## 7. Trigger it
Comment `@paseo have a look at this` on an issue in that repository, from the account you listed in `from_users`.
Open the project's **Activity** tab. You should see the event received and routed, and an execution in **Executions**. The agent itself appears in the Paseo app on that machine.
Nothing happened? [Activity](/docs/hub/activity) has the checklist.
@@ -0,0 +1,49 @@
---
title: Discord for Hub
description: Create the Discord application your Hub uses, connect a guild, and write Discord triggers.
nav: Discord app
order: 76
category: Hub
---
# Discord for Hub
Hub connects to Discord over the gateway with a bot you own. Unlike GitHub and Slack, Discord does not post to your Hub. Hub holds an outbound connection, so it needs no public webhook beyond the OAuth callback.
## Create the application
Go to the [Discord developer portal](https://discord.com/developers/applications) → **New Application**.
Under **Bot**:
- Add a bot and copy its token.
- Enable **Message Content Intent**. Without it the bot receives empty messages and no trigger can match.
- Server Members Intent is not needed.
Under **OAuth2**, add the redirect URL:
```text
https://hub.example.com/api/integrations/discord/callback
```
Replace `hub.example.com` with your `PASEO_HUB_APP_URL`.
## Configure Hub
| Value | Environment variable |
| -------------- | ----------------------- |
| Application ID | `DISCORD_CLIENT_ID` |
| Client Secret | `DISCORD_CLIENT_SECRET` |
| Bot token | `DISCORD_BOT_TOKEN` |
Restart Hub. Discord shows as **Ready** in Connections.
## Connect
Open **Connections → Discord → Connect**, choose the server, and authorize. Hub builds the invite with the permissions the bot needs, so you do not construct an invite URL yourself.
The connection appears with a slug derived from the guild name.
Because Hub holds one gateway connection for the whole deployment, one bot serves every organization and guild you connect.
Now write a trigger: [Discord triggers](/docs/hub/triggers/discord).
@@ -0,0 +1,80 @@
---
title: GitHub for Hub
description: Create the GitHub App your Hub uses, install it, and connect it to an organization.
nav: GitHub App
order: 74
category: Hub
---
# GitHub for Hub
Hub talks to GitHub through a GitHub App you own. One App serves your whole Hub; each account or organization that installs it becomes a connection.
## Create the App
Go to **Settings → Developer settings → GitHub Apps → New GitHub App** on the account that should own it.
Replace `hub.example.com` with your `PASEO_HUB_APP_URL`.
| Setting | Value |
| ------------------ | ---------------------------------------------------------- |
| Homepage URL | `https://hub.example.com` |
| Callback URL | `https://hub.example.com/api/integrations/github/callback` |
| Setup URL | `https://hub.example.com/api/integrations/github/setup` |
| Redirect on update | on |
| Webhook URL | `https://hub.example.com/webhook` |
| Webhook secret | a value you generate |
Repository permissions:
| Permission | Access | Why |
| ------------- | ------------ | ----------------------------------------- |
| Contents | Read & write | Read `.paseo/hub.yml`, let agents push |
| Issues | Read & write | Read comments, add reactions |
| Pull requests | Read & write | Read review comments, let agents open PRs |
| Metadata | Read | Required by GitHub |
Subscribe to events:
- Issue comment
- Issues
- Pull request review
- Pull request review comment
- Push
Push is what makes configuration sync work. Without it, Hub never learns that `.paseo/hub.yml` changed.
## Configure Hub
From the App's settings page, collect:
| Value | Environment variable |
| ------------------------- | -------------------------- |
| App ID | `GITHUB_APP_ID` |
| The slug in the App's URL | `GITHUB_APP_SLUG` |
| Client ID | `GITHUB_APP_CLIENT_ID` |
| A generated client secret | `GITHUB_APP_CLIENT_SECRET` |
| A generated private key | `GITHUB_APP_PRIVATE_KEY` |
| The webhook secret | `GITHUB_WEBHOOK_SECRET` |
The private key downloads as a PEM file. Pass its contents in `GITHUB_APP_PRIVATE_KEY`, or its path in `GITHUB_APP_PRIVATE_KEY_PATH`.
Restart Hub. GitHub should now show as **Ready** in Connections.
## Connect
Open **Connections → GitHub → Connect**. Hub sends you to GitHub to install the App, then binds the installation to your organization.
Start from Hub, not from GitHub's own install button. GitHub only calls the setup URL when an installation is created or changed, so installing directly can leave you on a settings page with nothing bound.
The connection appears with a slug derived from the account: an installation on `getpaseo` becomes `getpaseo-github`. That slug is how configuration names this connection.
Connect as many installations as you need. A personal account and several organizations can coexist in one Hub organization.
## What the connection gives you
- **Events.** Comments, issues, and reviews from every repository the installation can see. See [GitHub triggers](/docs/hub/triggers/github).
- **Configuration sync.** Any repository in the installation can hold `.paseo/hub.yml`. See [Configuration](/docs/hub/configuration).
- **Tokens.** Scoped, per-execution GitHub credentials.
Which repositories the installation covers is a GitHub setting. Change it on GitHub, not in Paseo.
+127
View File
@@ -0,0 +1,127 @@
---
title: Self-hosting Hub
description: Deploy Paseo Hub with PostgreSQL and a public HTTPS origin, using Docker Compose or Fly.
nav: Self-hosting
order: 73
category: Hub
---
# Self-hosting Hub
Hub is a Node service backed by PostgreSQL. Connecting external providers requires a public HTTPS URL for their callbacks and webhooks.
1. Deploy Hub with [Docker Compose](#docker-compose) or [Fly](#fly).
2. Create the [GitHub App](/docs/hub/self-hosting/github-app), [Slack app](/docs/hub/self-hosting/slack-app), and [Discord app](/docs/hub/self-hosting/discord-app) you want.
3. Follow the [quickstart](/docs/hub/quickstart).
Migrations run automatically at startup. Hub does not start listening when a migration fails.
## Configuration
Hub has one public URL and one persistent application secret:
| Variable | Purpose |
| ----------------------- | ------------------------------------------------------------------ |
| `PASEO_HUB_APP_URL` | Public origin used by the dashboard, authentication, and callbacks |
| `PASEO_HUB_AUTH_SECRET` | Protects browser sessions and derives execution credentials |
| `DATABASE_URL` | PostgreSQL connection string |
Generate `PASEO_HUB_AUTH_SECRET` once and keep it across restarts:
```sh
openssl rand -hex 32
```
Changing it signs everyone out and invalidates completion credentials for executions that are still running.
Bootstrap the first owner with:
```dotenv
PASEO_BOOTSTRAP_ORGANIZATION=My organization
PASEO_BOOTSTRAP_OWNER_EMAIL=me@example.com
PASEO_BOOTSTRAP_OWNER_PASSWORD=replace-with-a-temporary-password
```
The password must be at least 12 characters. Sign in with it once, replace it in the dashboard, then remove `PASEO_BOOTSTRAP_OWNER_PASSWORD` from the deployment. Hub keeps the account and organization.
### Providers
Set the group for each provider you intend to connect. A provider with missing credentials shows as **Setup needed** in Connections.
```sh
# GitHub
GITHUB_APP_SLUG=
GITHUB_APP_ID=
GITHUB_APP_CLIENT_ID=
GITHUB_APP_CLIENT_SECRET=
GITHUB_APP_PRIVATE_KEY= # or GITHUB_APP_PRIVATE_KEY_PATH
GITHUB_WEBHOOK_SECRET=
# Slack
SLACK_APP_ID=
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
SLACK_SIGNING_SECRET=
# Discord
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_BOT_TOKEN=
```
See [GitHub](/docs/hub/self-hosting/github-app), [Slack](/docs/hub/self-hosting/slack-app), and [Discord](/docs/hub/self-hosting/discord-app) for where each value comes from.
## Docker Compose
The repository contains Hub and PostgreSQL as one Compose stack:
```sh
git clone https://github.com/getpaseo/hub.git
cd hub
cp .env.example .env
```
Set `PASEO_HUB_APP_URL`, `PASEO_HUB_AUTH_SECRET`, and the three bootstrap values in `.env`, then run:
```sh
docker compose up -d
```
The stack publishes Hub on port `3000` and stores PostgreSQL data in a named volume. The Hub image is `ghcr.io/getpaseo/hub:latest`.
When a reverse proxy terminates HTTPS, set `PASEO_HUB_TRUSTED_CLIENT_IP_HEADER` to the header carrying the original client IP.
## Fly
Clone the repository and create an app and database under names you control:
```sh
git clone https://github.com/getpaseo/hub.git
cd hub
fly apps create your-hub
fly postgres create --name your-hub-db
fly postgres attach your-hub-db -a your-hub
```
Set the application secret and bootstrap account, along with credentials for the providers you use:
```sh
fly secrets set -a your-hub \
PASEO_HUB_AUTH_SECRET="$(openssl rand -hex 32)" \
PASEO_BOOTSTRAP_ORGANIZATION="My organization" \
PASEO_BOOTSTRAP_OWNER_EMAIL=me@example.com \
PASEO_BOOTSTRAP_OWNER_PASSWORD=replace-with-a-temporary-password
```
Deploy the Dockerfile from the repository:
```sh
fly deploy -a your-hub \
-e PASEO_HUB_APP_URL=https://your-hub.fly.dev
```
Keep one machine running. Hub holds the Discord gateway connection and dispatches events to daemons, so a stopped machine misses events.
## Upgrades
Pull the new image or source and deploy it. Migrations are forward-only. Back up PostgreSQL first; it contains configuration revisions, connections, and execution history.
+69
View File
@@ -0,0 +1,69 @@
---
title: Slack for Hub
description: Create the Slack app your Hub uses, connect a workspace, and write Slack triggers.
nav: Slack app
order: 75
category: Hub
---
# Slack for Hub
Hub receives Slack mentions over the Events API. Socket Mode is not used, so your Hub must be reachable from Slack.
## Create the app
Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App → From a manifest**, and paste this after replacing `hub.example.com` with your `PASEO_HUB_APP_URL`:
```yaml
display_information:
name: Paseo
features:
bot_user:
display_name: Paseo
always_online: false
oauth_config:
redirect_urls:
- https://hub.example.com/api/integrations/slack/callback
scopes:
bot:
- app_mentions:read
- chat:write
- reactions:write
settings:
event_subscriptions:
request_url: https://hub.example.com/api/integrations/slack/events
bot_events:
- app_mention
interactivity:
is_enabled: false
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
```
## Configure Hub
From **Basic Information → App Credentials**, copy:
| Value | Environment variable |
| -------------- | ---------------------- |
| App ID | `SLACK_APP_ID` |
| Client ID | `SLACK_CLIENT_ID` |
| Client Secret | `SLACK_CLIENT_SECRET` |
| Signing Secret | `SLACK_SIGNING_SECRET` |
Restart Hub before Slack verifies the request URL. Hub needs the signing secret to answer the verification challenge.
## Connect
Open **Connections → Slack → Connect**, pick the workspace, and allow it.
Use Hub's button, not Slack's **Install to Workspace**. The install has to start from Hub so the workspace binds to your organization.
Then invite the bot to each channel it should watch:
```text
/invite @Paseo
```
Now write a trigger: [Slack triggers](/docs/hub/triggers/slack).
+118
View File
@@ -0,0 +1,118 @@
---
title: Discord triggers
description: Discord mentions as Hub triggers, snowflake filters, thread context, and the full paseo.event.discord data reference.
nav: Discord
order: 67
category: Hub
---
# Discord triggers
## Events
| `on` | Fires when |
| ----------------- | ------------------------------------------------- |
| `discord.mention` | The bot is mentioned in a guild channel or thread |
Mentioning a role the bot holds counts as mentioning the bot.
## Filters
Discord filters take snowflake IDs. Turn on **Developer Mode** in Discord's advanced settings, then right-click a channel or user to copy its ID.
```yaml
filters:
guild: "123456789012345678"
channels: ["234567890123456789"]
from_users: ["345678901234567890"]
```
Quote them. Unquoted snowflakes parse as numbers and lose precision.
- `from_users` matches the author's user id.
- `guild` is resolved to the connection on activation.
- `channels` matches the channel the message was posted in; in a thread, the thread's parent channel counts.
- `pattern` matches the start of the text after the mention.
## Replying
```yaml
allow_outputs:
- type: discord.reply
```
One plain-text reply per execution, in the triggering thread or channel.
## Repository access
A Discord trigger has no implicit GitHub credential. Name the connection you want:
```yaml
env:
GH_TOKEN: ${{ paseo.connections.acme-github.token }}
```
Hub will not guess between several GitHub installations.
## Data reference
The triggering message is `paseo.event.discord.trigger_message`:
| Path | Value |
| -------------------- | ----------------------------------------------------- |
| `id` | Message snowflake |
| `content` | Raw text, including the mention |
| `body` | Text with the mention stripped, usually what you want |
| `url` | Canonical message link |
| `author` | Author identity, including the user id |
| `channel` | Channel identity |
| `thread` | Thread identity, or `null` outside a thread |
| `created_at` | Message creation time |
| `attachments` | Typed list, see below |
| `referenced_message` | Identity of a replied-to message, or `null` |
Each attachment has `id`, `filename`, `url`, `content_type`, and `size`. A `referenced_message` carries `id`, `channel_id`, and `guild_id`, without the message content.
Also available: `paseo.event.discord.event_type` and `paseo.event.discord.guild.id`.
### Thread context
In a thread, `paseo.event.discord.trigger_thread_context.messages` holds the preceding messages, **oldest first**, never including the triggering message. Each entry exposes the same identity, content, author, channel, creation time, attachment, and reference fields as `trigger_message`.
This is how you give an agent the conversation rather than one line:
```yaml
prompt: |
Thread so far:
${{ paseo.event.discord.trigger_thread_context.messages }}
Latest request:
${{ paseo.event.discord.trigger_message.body }}
```
Outside a thread the list is empty.
## Example
```yaml
triggers:
- name: discord-fix
on: discord.mention
environment: dev
filters:
guild: "123456789012345678"
channels: ["234567890123456789"]
from_users: ["345678901234567890"]
agent:
provider: codex
mode: full-access
env:
GH_TOKEN: ${{ paseo.connections.acme-github.token }}
allow_outputs:
- type: discord.reply
prompt: |
${{ paseo.event.discord.trigger_message.body }}
Context: ${{ paseo.event.discord.trigger_message.url }}
Reply in the thread when you're done.
```
+113
View File
@@ -0,0 +1,113 @@
---
title: GitHub triggers
description: GitHub events Hub can trigger on, how filters match them, and the full paseo.event.github data reference.
nav: GitHub
order: 65
category: Hub
---
# GitHub triggers
## Events
| `on` | Fires when |
| ------------------------------------ | ------------------------------------- |
| `github.issue_comment` | A comment on an issue or pull request |
| `github.issues` | An issue is opened or edited |
| `github.pull_request_review` | A review is submitted |
| `github.pull_request_review_comment` | A comment on a diff |
Which repositories produce events is set on the GitHub connection's installation, not in Paseo.
## Filters
```yaml
filters:
repo: acme/api
contains: "@paseo"
from_users: [alice, bob]
```
- `from_users` matches the GitHub **login** of the event's sender.
- `repo` is `owner/name`, resolved to the repository's immutable id on activation.
- `contains` is a substring test; `pattern` must match at the start.
The text those two search depends on the event:
| Event | Text searched |
| ------------------------------------ | -------------------- |
| `github.issue_comment` | The comment body |
| `github.issues` | Issue title and body |
| `github.pull_request_review` | The review body |
| `github.pull_request_review_comment` | The comment body |
## Tokens
A GitHub-triggered execution automatically receives a `GH_TOKEN` scoped to the triggering repository, with `contents`, `issues`, and `pull_requests` write access. It is minted per execution and revoked when the execution ends. You do not map it in `env`.
For a token from a _different_ connection, or from a non-GitHub trigger, name it explicitly:
```yaml
env:
GH_TOKEN: ${{ paseo.connections.acme-github.token }}
```
## Data reference
GitHub's webhook payload is available directly under `paseo.event.github`, unflattened. Anything GitHub sends, you can read:
```yaml
prompt: |
${{ paseo.event.github.issue.title }}
by ${{ paseo.event.github.sender.login }}
on ${{ paseo.event.github.repository.full_name }}
```
Common paths by event:
| Event | Useful paths |
| ------------------------------------ | ------------------------------------------------------------------------------- |
| `github.issue_comment` | `comment.body`, `comment.html_url`, `issue.number`, `issue.title`, `issue.body` |
| `github.issues` | `issue.number`, `issue.title`, `issue.body`, `action` |
| `github.pull_request_review` | `review.body`, `review.state`, `pull_request.number` |
| `github.pull_request_review_comment` | `comment.body`, `comment.path`, `comment.diff_hunk`, `pull_request.number` |
All events also carry `sender.login`, `repository.full_name`, and `repository.default_branch`.
Hub adds an envelope alongside GitHub's fields:
| Path | Value |
| ---------------------- | ---------------------------------------------- |
| `delivery_id` | GitHub's webhook delivery id |
| `event_name` | `issue_comment`, `issues`, … |
| `repository_full_name` | `owner/name` |
| `installation_id` | The installation that delivered the event |
| `received_at` | When Hub accepted it |
| `trigger_url` | Canonical URL of the comment, issue, or review |
`trigger_url` is only present when the event has one. It is the field you usually want in a prompt.
## Example
```yaml
triggers:
- name: review-fix
on: github.pull_request_review_comment
environment: dev
filters:
repo: acme/api
contains: "@paseo"
from_users: [alice]
agent:
provider: codex
mode: full-access
prompt: |
${{ paseo.event.github.sender.login }} left this on PR
#${{ paseo.event.github.pull_request.number }},
in ${{ paseo.event.github.comment.path }}:
${{ paseo.event.github.comment.body }}
Fix it and push. Reply on the thread with gh when you're done.
${{ paseo.event.github.trigger_url }}
```
+94
View File
@@ -0,0 +1,94 @@
---
title: Hub triggers
description: How Hub matches an inbound event to a trigger: events, filters, and the allowlist that gates every execution.
nav: Triggers
order: 64
category: Hub
---
# Triggers
A trigger says: when this event arrives, from this source, from these people, run this agent with this prompt.
```yaml
triggers:
- name: mention
on: github.issue_comment
environment: dev
filters:
repo: acme/api
contains: "@paseo"
from_users: [alice]
agent:
provider: codex
mode: full-access
prompt: ${{ paseo.event.github.comment.body }}
```
Field-by-field detail is in the [`hub.yml` reference](/docs/hub/configuration/hub-yml). This page covers matching.
## Events
| `on` | Fires when |
| ------------------------------------ | ------------------------------------- |
| `github.issue_comment` | A comment on an issue or pull request |
| `github.issues` | An issue is opened or edited |
| `github.pull_request_review` | A review is submitted |
| `github.pull_request_review_comment` | A comment on a diff |
| `slack.mention` | The bot is mentioned in a channel |
| `discord.mention` | The bot is mentioned in a guild |
| `manual.run` | A run started from the API |
Each provider page documents its events and the data they expose:
- [GitHub triggers](/docs/hub/triggers/github)
- [Slack triggers](/docs/hub/triggers/slack)
- [Discord triggers](/docs/hub/triggers/discord)
## Filters
`filters` is required, and `from_users` must be present and non-empty. A trigger without it is rejected at validation.
The allowlist is what keeps a stranger's comment on a public issue from starting an agent on your machine. There is no default, because a safe default differs per repository.
| Filter | Applies to | Matches |
| ------------ | -------------- | --------------------------------------------------------------- |
| `from_users` | all | GitHub: login. Slack and Discord: **user id**, not display name |
| `repo` | GitHub | `owner/name` |
| `workspace` | Slack | Team id, `T01234567` |
| `guild` | Discord | Guild id |
| `channels` | Slack, Discord | Channel ids |
| `contains` | all | Substring of the message text |
| `pattern` | all | Prefix of the message text |
| `connection` | all | A connection slug, when the organization has several |
All conditions must pass. There is no `any` mode.
## Which connection an event comes from
`repo`, `workspace`, and `guild` are resolved to immutable ids when the configuration activates, along with the connection that owns them. Naming a resource the organization has no connection for fails activation, so you find out on push rather than when someone comments.
Omit the resource filter and the trigger listens to every connection of that provider in the organization. To pin it to one:
```yaml
filters:
connection: acme-github
from_users: [alice]
```
See [How Hub works](/docs/hub/concepts) for what activation compiles.
## When two triggers match
Both run. Triggers are not ordered and do not shadow each other, in one configuration or across projects.
## Replying
`allow_outputs` gives the agent a single-use `reply` tool for the conversation that triggered it:
```yaml
allow_outputs:
- type: slack.reply
```
Only `slack.reply` and `discord.reply` exist. GitHub-triggered agents reply with the scoped `GH_TOKEN` they already have, so `gh issue comment` works.
+98
View File
@@ -0,0 +1,98 @@
---
title: Slack triggers
description: Slack mentions as Hub triggers, ID-based filters, replies, and the full paseo.event.slack data reference.
nav: Slack
order: 66
category: Hub
---
# Slack triggers
## Events
| `on` | Fires when |
| --------------- | -------------------------------------------------------- |
| `slack.mention` | The bot is mentioned in a channel it has been invited to |
The bot must be in the channel. `/invite @Paseo` first, or nothing arrives.
## Filters
Slack filters take IDs, never display names. Copy a channel ID from the channel's details; copy a user ID from a member's profile menu.
```yaml
filters:
workspace: T01234567
channels: [C01234567]
from_users: [U01234567]
```
- `from_users` matches the Slack user id of the author.
- `workspace` is the team id, resolved to the connection on activation.
- `channels` matches the channel id the message was posted in.
- `pattern` matches the start of the text **after** the mention, and must end on a word boundary. `contains` is treated the same as `pattern` here.
The mention itself is always required. A message that does not mention the bot never produces an event.
## Replying
```yaml
allow_outputs:
- type: slack.reply
```
That gives the agent one plain-text reply, posted in the thread. A root message gets a new thread; a threaded message gets a reply in the same thread.
## Data reference
Everything lives under `paseo.event.slack`.
| Path | Value |
| ------------ | --------------------------------- |
| `event_type` | `mention` |
| `event_id` | Slack's Events API event id |
| `event_ts` | Event timestamp from the envelope |
| `event_time` | Envelope delivery time |
| `team` | Workspace identity |
| `app` | The installed app identity |
The message itself is `paseo.event.slack.trigger_message`:
| Path | Value |
| ------------ | ----------------------------------------------------- |
| `ts` | Native message timestamp, Slack's message id |
| `content` | Raw text, including the `<@BOT>` mention |
| `body` | Text with the mention stripped, usually what you want |
| `author` | Author identity, including the user id |
| `channel` | Channel identity |
| `thread` | Thread identity, or `null` for a root message |
| `created_at` | Derived from the message `ts` |
Use `body`, not `content`, unless you specifically need the mention text.
## Example
```yaml
triggers:
- name: slack-mention
on: slack.mention
environment: dev
filters:
workspace: T01234567
channels: [C01234567]
from_users: [U01234567]
agent:
provider: codex
mode: full-access
idle_timeout: 10m
allow_outputs:
- type: slack.reply
prompt: |
${{ paseo.event.slack.trigger_message.body }}
Reply in the thread when you're done.
```
## Limits
Direct messages, slash commands, and interactive components do not produce triggers. Enterprise Grid org-wide installs are not supported.
+2
View File
@@ -19,6 +19,8 @@ Both concepts use the same cron engine, but their product surfaces stay separate
Cron is the canonical cadence. The CLI accepts simple presets such as `5m` or `1h`, but compiles them to cron rather than storing a separate interval type.
Both run on a cadence you set. To start an agent from an external event instead — a comment, a mention — see [Hub](/docs/hub).
## What it's for
- **Fresh recurring jobs:** start a clean agent for daily triage, reports, or maintenance.
+1
View File
@@ -141,3 +141,4 @@ Paseo never stores or transmits provider API keys. Agents run in your user conte
- **Never bind to 0.0.0.0 without a password**, without one, any device on your network can connect
- **Scope Docker mounts tightly**, agents can access mounted workspaces and provider credentials
- **Keep your daemon updated**, security improvements are released regularly
- **Protect the Hub configuration branch**, push access to `.paseo/hub.yml` controls what that project can reach, see [How Hub works](/docs/hub/concepts)
+1 -1
View File
@@ -2,7 +2,7 @@
title: Troubleshooting
description: Why Paseo can't find a provider you've installed, and how to fix the PATH and environment mismatches behind most setup issues.
nav: Common problems
order: 50
order: 90
category: Troubleshooting
---
+1
View File
@@ -38,6 +38,7 @@ Paseo is a self-hostable platform for running and orchestrating coding agents. I
- The CLI exposes the same surface as the app. Anything in the UI is scriptable.
- [Paseo tools](/docs/orchestration). Agents can drive Paseo themselves: create isolated workspaces, spawn subagents, open terminals, and send prompts.
- [Hub](/docs/hub). A service you host that starts agents on your daemon when someone mentions you on GitHub, Slack, or Discord.
## What it isn't