Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,37 @@ SUPERVISOR_TOKEN=
# Set to runsc to run every computer under gVisor, if the host has it. Unset, a computer is an
# ordinary container and shares the host kernel, which is worth knowing when the Bot is not ours.
COMPUTER_RUNTIME=

# ---------------------------------------------------------------------------
# Routines: work a Bot does without being asked
# ---------------------------------------------------------------------------

# The switch. `off` leaves routines writable and readable, and stops every way of running one: the
# clock, Run now, and a webhook delivery alike.
#
# All three, because this is what a second copy of a deployment wants. Restoring a database dump onto
# a development machine otherwise means somebody's real routines browsing their real systems at eight
# o'clock, and the restored webhook triggers keep their endpoint ids and their secrets, so the
# senders that were configured against them go on setting Bots working. The run is unattended either
# way, so nobody is there to notice. With this off the receiver below is not started at all. It is a
# whole-deployment switch for that reason, and it is announced at boot rather than being a silent
# absence of activity.
# ROUTINE_SCHEDULER=off

# Where webhook deliveries arrive. Defaults to one above PORT. Not started when ROUTINE_SCHEDULER is
# off, because a delivery does not go through the clock and closing one door is not closing the other.
#
# A port of its own, not a path on the API. This is the one surface in the product meant to be
# reachable by a third party, and the way to keep the rest of the API away from it is for the rest of
# the API not to be on it: this listener serves /health and /hooks/:endpointId and answers 404 to
# everything else, including anything that looks like an API path.
#
# Each trigger has a random endpoint id and a bearer secret, shown once when it is created or
# rotated and stored only as a hash. A new trigger keeps its first authenticated delivery as a sample
# and runs nothing until somebody has looked at what arrived and confirmed it in /admin/webhooks.
# ROUTINE_WEBHOOK_PORT=3002

# What the receiver binds to. 127.0.0.1 unless this says otherwise, the same posture as everything
# else here: an endpoint reachable from the internet should be a decision somebody made, not one they
# inherited by starting the server. Put a reverse proxy in front of it rather than opening it wide.
# ROUTINE_WEBHOOK_HOST=127.0.0.1
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),
| `/channel/:id` | Converse with one coworker and view its live screen/profile panel. |
| `/bot` | Direct chat with a Bot; `?agent=<id>` selects one. |
| `/skills` | Create and enable personal skills. |
| `/routines` | Schedule unattended work, run one now, and read its run history. |
| `/settings` | User preferences. |
| `/admin/connectors` | Configure deployment knowledge sources. |
| `/admin/credentials` | Store write-only encrypted credentials. |
Expand All @@ -120,6 +121,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),
| `/admin/components` | Publish components and govern which Bots may use them. |
| `/admin/playground` | Draft and publish sandboxed components in the browser. |
| `/admin/plugins` | Configure MCP servers, MCP grants, and deployment skills. |
| `/admin/webhooks` | Create webhook triggers, confirm a first delivery, rotate secrets. |
| `/admin/audit` | Review permitted, refused, and failed actions. |

## Features
Expand All @@ -133,6 +135,8 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),
- **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component.
- **Governed MCP**: a curated catalogue ships for Atlassian, Box, Slack, Salesforce and ServiceNow. Custom servers must pass URL checks, and any tool not positively classified as a read is treated as a write.
- **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer.
- **Routines run unattended, through the same gateway**: a scheduled run drives the Bot server side with no browser in the loop, and every tool call still goes through the gateway, so the same policy decides it and the same audit row records it. A window the deployment slept through is recorded as `missed` rather than fired late. `run.unattended` is a policy attribute, so a deployment can write `run.unattended && intent == "activate"` to let a routine read and never press.
- **Webhook triggers on their own port**: deliveries arrive on `ROUTINE_WEBHOOK_PORT` (default `PORT + 1`), which serves `/health` and `/hooks/:endpointId` and nothing else. A bearer secret is stored only as a hash and compared in constant time, and a new trigger keeps its FIRST authenticated delivery as a sample and runs nothing until a person has looked at it. Triggers are an administrator's: they are the deployment's exposure rather than one person's work, and `/admin/webhooks` lists every one of them.
- **An audit trail you can read**: `/admin/audit` lists what was permitted, what was refused and what failed, and every refusal carries the rule that caused it.
- **Credentials encrypted at rest**: stored through `/admin/credentials`, never returned by an API, and redacted from audit events.
- **Loopback by default**: computers bind to `127.0.0.1` and require a per-container token, so nothing reaches a logged-in browser by knowing its port.
Expand Down
8 changes: 8 additions & 0 deletions app/src/components/admin/admin-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
IconPlugConnected,
IconPuzzle,
IconShieldCheck,
IconWebhook,
} from "@tabler/icons-react";
import { Link, type LinkOptions } from "@tanstack/react-router";
import type * as React from "react";
Expand Down Expand Up @@ -84,6 +85,13 @@ const GROUPS: {
icon: IconCode,
linkOptions: { to: "/admin/playground" },
},
// Under what Bots can do rather than what they can reach: a trigger does not grant a Bot
// anything, it lets somebody else start work the Bot could already do.
{
title: "Webhooks",
icon: IconWebhook,
linkOptions: { to: "/admin/webhooks" },
},
],
},
{
Expand Down
25 changes: 25 additions & 0 deletions app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
IconBolt,
IconClockPlay,
IconLogout,
IconPlus,
IconSearch,
Expand Down Expand Up @@ -286,6 +287,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<span className="text-sm trackint-tight">Skills</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
{/*
* Beside Skills rather than inside Admin, and for the same reason: a routine is work a
* person schedules for their own Bot, not a deployment-wide setting. What an
* administrator governs is the boundary those runs meet, which is already in Admin.
*/}
<SidebarMenuButton
className="hover:bg-foreground/5 h-10"
render={(props) => (
<Link
{...props}
to="/routines"
activeProps={{
className: "bg-foreground/5",
}}
/>
)}
>
<div className="size-[28px] flex items-center justify-center">
<IconClockPlay />
</div>
<span className="text-sm trackint-tight">Routines</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="hover:bg-foreground/5 h-10"
Expand Down
187 changes: 187 additions & 0 deletions app/src/lib/routines/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { queryOptions } from "@tanstack/react-query";

/**
* Routines and webhook triggers, as the browser sees them.
*
* The shapes mirror the server's records rather than being a convenience view of them, so a field
* added there shows up here as a type error rather than as a value the page silently ignores.
*/

export type RoutineSchedule =
| { type: "once"; at: string }
/** `time` is HH:MM UTC and `weekdays` is 0 for Sunday through 6 for Saturday, as the server's is. */
| { type: "daily"; time: string; weekdays: number[] };

export type RoutineRunStatus =
| "queued"
| "running"
| "completed"
| "failed"
/** The deployment was not running when the window came round. See the page for why this exists. */
| "missed";

export type RoutineRun = {
id: string;
routineId: string;
trigger: "schedule" | "manual" | "webhook";
status: RoutineRunStatus;
startedAt: string;
finishedAt: string | null;
summary: string | null;
error: string | null;
threadId: string | null;
};

export type Routine = {
id: string;
agentId: string;
ownerUserId: string;
name: string;
prompt: string;
schedule: RoutineSchedule;
enabled: boolean;
createdAt: string;
updatedAt: string;
/** Computed by the server on every read. Null for a schedule that will never call again. */
nextDueAt: string | null;
lastRun: RoutineRun | null;
};

export type WebhookTrigger = {
id: string;
/** The public path segment. The full URL is built from it where it is shown. */
endpointId: string;
name: string;
routineId: string | null;
agentId: string | null;
prompt: string | null;
enabled: boolean;
/** True until somebody has looked at a real delivery and confirmed it. */
verificationPending: boolean;
verifiedAt: string | null;
/** The first authenticated delivery, so the confirmation is an informed one. */
sample: Record<string, unknown> | null;
eventTypes: string[];
deliveryCount: number;
lastReceivedAt: string | null;
createdAt: string;
};

export const routineKeys = {
all: ["routines"] as const,
list: () => ["routines", "list"] as const,
runs: (routineId: string) => ["routines", "runs", routineId] as const,
triggers: () => ["routines", "triggers"] as const,
};

export function routineListQueryOptions() {
return queryOptions({
queryKey: routineKeys.list(),
/*
* Refetched while the page is open, because a routine's state changes without anybody on this
* screen doing anything: the clock fires, a run finishes, a delivery arrives. A page that only
* updated when you pressed something would show a run as still going for as long as you looked
* at it.
*/
refetchInterval: 15_000,
queryFn: async (): Promise<Routine[]> => {
const response = await fetch("/api/routines", { credentials: "include" });
if (!response.ok) throw new Error("Routines could not be loaded.");
return ((await response.json()) as { routines: Routine[] }).routines;
},
});
}

export function routineRunsQueryOptions(routineId: string) {
return queryOptions({
queryKey: routineKeys.runs(routineId),
enabled: routineId.length > 0,
refetchInterval: 15_000,
queryFn: async (): Promise<RoutineRun[]> => {
const response = await fetch(
`/api/routines/${encodeURIComponent(routineId)}/runs`,
{ credentials: "include" },
);
if (!response.ok) throw new Error("The run history could not be loaded.");
return ((await response.json()) as { runs: RoutineRun[] }).runs;
},
});
}

/**
* Every trigger in the deployment, which is what the administrators' page shows.
*
* Not this person's own, unlike the routines above. A trigger is a URL somebody outside can call,
* so the useful list is all of them; the server refuses this to anybody who is not an administrator.
*/
export function webhookTriggerQueryOptions() {
return queryOptions({
queryKey: routineKeys.triggers(),
queryFn: async (): Promise<WebhookTrigger[]> => {
const response = await fetch("/api/routines/triggers", {
credentials: "include",
});
if (!response.ok) throw new Error("Triggers could not be loaded.");
return ((await response.json()) as { triggers: WebhookTrigger[] })
.triggers;
},
});
}

/**
* Call the routines API and surface the server's own sentence when it refuses.
*
* The server refuses for reasons this page cannot check, and its wording is the only useful part of
* a failure. Paraphrasing it into "That did not work" throws away the sentence somebody needs.
*/
export async function callRoutines(
path: string,
init: RequestInit,
): Promise<unknown> {
const response = await fetch(`/api/routines${path}`, {
credentials: "include",
headers: { "content-type": "application/json" },
...init,
});
const body = (await response.json().catch(() => null)) as {
error?: string;
detail?: string;
} | null;
if (!response.ok) {
throw new Error(body?.error ?? body?.detail ?? "That did not work.");
}
return body;
}

/**
* How a schedule reads on screen.
*
* The same words as routines/schedule.ts on the server, written twice because the two sides share no
* module and the browser cannot import from the server's. Nothing enforces the agreement, so the
* cheap thing that can be done is done: the same normalisation, through `Date`, rather than a string
* slice that quietly disagrees the moment a schedule arrives spelled `+00:00` instead of `Z`.
*/
export function describeSchedule(schedule: RoutineSchedule): string {
if (schedule.type === "once") {
return `Once, at ${new Date(schedule.at).toISOString().replace("T", " ").slice(0, 16)} UTC`;
}
if (schedule.weekdays.length === 0) return "Never, no days are selected";
if (schedule.weekdays.length === 7)
return `Every day at ${schedule.time} UTC`;
return `${schedule.weekdays
.map((day) => DAY_NAMES[day] ?? String(day))
.join(", ")} at ${schedule.time} UTC`;
}

export const DAY_NAMES = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
] as const;

/** Weekdays, as the button row offers them: Monday first, which is how people read a week. */
export const WEEK = [1, 2, 3, 4, 5, 6, 0] as const;
Loading