From dfd18a44a7034df4cb521e52633f2f561604649f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 4 Aug 2026 21:58:27 +0200 Subject: [PATCH] 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 --- packages/website/src/components/docs-nav.tsx | 89 +++++++-- packages/website/src/docs.ts | 28 ++- public-docs/cli.md | 10 + public-docs/connectivity.md | 2 + public-docs/hub/activity.md | 58 ++++++ public-docs/hub/concepts.md | 79 ++++++++ public-docs/hub/configuration/examples.md | 182 +++++++++++++++++++ public-docs/hub/configuration/hub-yml.md | 150 +++++++++++++++ public-docs/hub/configuration/index.md | 59 ++++++ public-docs/hub/daemons.md | 83 +++++++++ public-docs/hub/faq.md | 51 ++++++ public-docs/hub/hosted.md | 13 ++ public-docs/hub/index.md | 55 ++++++ public-docs/hub/quickstart.md | 79 ++++++++ public-docs/hub/self-hosting/discord-app.md | 49 +++++ public-docs/hub/self-hosting/github-app.md | 80 ++++++++ public-docs/hub/self-hosting/index.md | 127 +++++++++++++ public-docs/hub/self-hosting/slack-app.md | 69 +++++++ public-docs/hub/triggers/discord.md | 118 ++++++++++++ public-docs/hub/triggers/github.md | 113 ++++++++++++ public-docs/hub/triggers/index.md | 94 ++++++++++ public-docs/hub/triggers/slack.md | 98 ++++++++++ public-docs/schedules.md | 2 + public-docs/security.md | 1 + public-docs/troubleshooting.md | 2 +- public-docs/why.md | 1 + 26 files changed, 1670 insertions(+), 22 deletions(-) create mode 100644 public-docs/hub/activity.md create mode 100644 public-docs/hub/concepts.md create mode 100644 public-docs/hub/configuration/examples.md create mode 100644 public-docs/hub/configuration/hub-yml.md create mode 100644 public-docs/hub/configuration/index.md create mode 100644 public-docs/hub/daemons.md create mode 100644 public-docs/hub/faq.md create mode 100644 public-docs/hub/hosted.md create mode 100644 public-docs/hub/index.md create mode 100644 public-docs/hub/quickstart.md create mode 100644 public-docs/hub/self-hosting/discord-app.md create mode 100644 public-docs/hub/self-hosting/github-app.md create mode 100644 public-docs/hub/self-hosting/index.md create mode 100644 public-docs/hub/self-hosting/slack-app.md create mode 100644 public-docs/hub/triggers/discord.md create mode 100644 public-docs/hub/triggers/github.md create mode 100644 public-docs/hub/triggers/index.md create mode 100644 public-docs/hub/triggers/slack.md diff --git a/packages/website/src/components/docs-nav.tsx b/packages/website/src/components/docs-nav.tsx index f9188213cb..f356dcfc44 100644 --- a/packages/website/src/components/docs-nav.tsx +++ b/packages/website/src/components/docs-nav.tsx @@ -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(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 ( 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) => { + if (isActive) { + event.preventDefault(); + toggle(); + return; + } + setIsOpen(true); + onNavigate?.(); + }, + [isActive, onNavigate, toggle], + ); + + const hasChildren = node.children.length > 0; + const chevron = isOpen ? : ; + 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 (
- - {isOpen && ( + {node.href === undefined ? ( + + ) : ( + + {node.label} + {hasChildren && chevron} + + )} + {isOpen && hasChildren && (
diff --git a/packages/website/src/docs.ts b/packages/website/src/docs.ts index 3904f82644..aa89c2315a 100644 --- a/packages/website/src/docs.ts +++ b/packages/website/src/docs.ts @@ -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 | undefined; for (const segment of directories) { let group = current.find( (node): node is Extract => @@ -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; } diff --git a/public-docs/cli.md b/public-docs/cli.md index 1e331525ab..9075b8e00c 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -193,6 +193,16 @@ paseo daemon stop # Stop the daemon Use `PASEO_HOME` to run multiple isolated daemon instances. +## Hub + +```bash +paseo hub connect # 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. diff --git a/public-docs/connectivity.md b/public-docs/connectivity.md index 371e3808bb..c1dace166b 100644 --- a/public-docs/connectivity.md +++ b/public-docs/connectivity.md @@ -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) diff --git a/public-docs/hub/activity.md b/public-docs/hub/activity.md new file mode 100644 index 0000000000..59a15411a5 --- /dev/null +++ b/public-docs/hub/activity.md @@ -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. diff --git a/public-docs/hub/concepts.md b/public-docs/hub/concepts.md new file mode 100644 index 0000000000..4bb5bc6701 --- /dev/null +++ b/public-docs/hub/concepts.md @@ -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: ` 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. diff --git a/public-docs/hub/configuration/examples.md b/public-docs/hub/configuration/examples.md new file mode 100644 index 0000000000..795825a7b5 --- /dev/null +++ b/public-docs/hub/configuration/examples.md @@ -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. diff --git a/public-docs/hub/configuration/hub-yml.md b/public-docs/hub/configuration/hub-yml.md new file mode 100644 index 0000000000..6af336983c --- /dev/null +++ b/public-docs/hub/configuration/hub-yml.md @@ -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.. }}` 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..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. diff --git a/public-docs/hub/configuration/index.md b/public-docs/hub/configuration/index.md new file mode 100644 index 0000000000..000972c030 --- /dev/null +++ b/public-docs/hub/configuration/index.md @@ -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). diff --git a/public-docs/hub/daemons.md b/public-docs/hub/daemons.md new file mode 100644 index 0000000000..97880b9091 --- /dev/null +++ b/public-docs/hub/daemons.md @@ -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 +``` + +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. diff --git a/public-docs/hub/faq.md b/public-docs/hub/faq.md new file mode 100644 index 0000000000..2f51f756e6 --- /dev/null +++ b/public-docs/hub/faq.md @@ -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. diff --git a/public-docs/hub/hosted.md b/public-docs/hub/hosted.md new file mode 100644 index 0000000000..3d6009c9bc --- /dev/null +++ b/public-docs/hub/hosted.md @@ -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. diff --git a/public-docs/hub/index.md b/public-docs/hub/index.md new file mode 100644 index 0000000000..16a85259bd --- /dev/null +++ b/public-docs/hub/index.md @@ -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. diff --git a/public-docs/hub/quickstart.md b/public-docs/hub/quickstart.md new file mode 100644 index 0000000000..ef54aaa04e --- /dev/null +++ b/public-docs/hub/quickstart.md @@ -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. diff --git a/public-docs/hub/self-hosting/discord-app.md b/public-docs/hub/self-hosting/discord-app.md new file mode 100644 index 0000000000..d11aa01660 --- /dev/null +++ b/public-docs/hub/self-hosting/discord-app.md @@ -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). diff --git a/public-docs/hub/self-hosting/github-app.md b/public-docs/hub/self-hosting/github-app.md new file mode 100644 index 0000000000..14c1409d1d --- /dev/null +++ b/public-docs/hub/self-hosting/github-app.md @@ -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. diff --git a/public-docs/hub/self-hosting/index.md b/public-docs/hub/self-hosting/index.md new file mode 100644 index 0000000000..b484ab2b1f --- /dev/null +++ b/public-docs/hub/self-hosting/index.md @@ -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. diff --git a/public-docs/hub/self-hosting/slack-app.md b/public-docs/hub/self-hosting/slack-app.md new file mode 100644 index 0000000000..bc648d08e2 --- /dev/null +++ b/public-docs/hub/self-hosting/slack-app.md @@ -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). diff --git a/public-docs/hub/triggers/discord.md b/public-docs/hub/triggers/discord.md new file mode 100644 index 0000000000..75549aae1e --- /dev/null +++ b/public-docs/hub/triggers/discord.md @@ -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. +``` diff --git a/public-docs/hub/triggers/github.md b/public-docs/hub/triggers/github.md new file mode 100644 index 0000000000..181022e32b --- /dev/null +++ b/public-docs/hub/triggers/github.md @@ -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 }} +``` diff --git a/public-docs/hub/triggers/index.md b/public-docs/hub/triggers/index.md new file mode 100644 index 0000000000..e3010d9679 --- /dev/null +++ b/public-docs/hub/triggers/index.md @@ -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. diff --git a/public-docs/hub/triggers/slack.md b/public-docs/hub/triggers/slack.md new file mode 100644 index 0000000000..70ad7acba6 --- /dev/null +++ b/public-docs/hub/triggers/slack.md @@ -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. diff --git a/public-docs/schedules.md b/public-docs/schedules.md index 5a11307376..2f6405a2ba 100644 --- a/public-docs/schedules.md +++ b/public-docs/schedules.md @@ -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. diff --git a/public-docs/security.md b/public-docs/security.md index 16bf086b50..e68e53f1bc 100644 --- a/public-docs/security.md +++ b/public-docs/security.md @@ -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) diff --git a/public-docs/troubleshooting.md b/public-docs/troubleshooting.md index 556b837488..70630e1ee9 100644 --- a/public-docs/troubleshooting.md +++ b/public-docs/troubleshooting.md @@ -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 --- diff --git a/public-docs/why.md b/public-docs/why.md index 130c720fa8..44ac4a90c9 100644 --- a/public-docs/why.md +++ b/public-docs/why.md @@ -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