Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
339448f
feat(scheduler): Add Slack scheduled tasks
dcramer May 19, 2026
e6ed45e
fix(scheduler): Restrict scheduled task mutations
dcramer May 20, 2026
7aa4c6e
fix(slack): Preserve webhook workspace team context
dcramer May 20, 2026
f8047df
fix(scheduler): Preserve recurrence anchors on edits
dcramer May 20, 2026
fce81de
fix(scheduler): Prevent overlapping task runs
dcramer May 20, 2026
fd98d77
fix(scheduler): Block auth-paused scheduled runs
dcramer May 20, 2026
136f747
fix(scheduler): Clear stale block reasons on resume
dcramer May 20, 2026
bb048ef
fix(scheduler): Release blocked retry claims
dcramer May 20, 2026
e05c979
fix(scheduler): Disable scheduled auth flows
dcramer May 20, 2026
825c31e
fix(scheduler): Reclaim abandoned pending runs
dcramer May 20, 2026
2282725
fix(scheduler): Harden scheduled run state
dcramer May 20, 2026
e9e7946
feat(scheduler): Harden Slack scheduled task execution
dcramer May 26, 2026
54f4e56
feat(scheduler): Add trusted plugin dispatch heartbeat
dcramer May 26, 2026
9847670
feat(scheduler): Move scheduler tools into trusted plugin
dcramer May 26, 2026
78057f4
fix(scheduler): Keep schedule guidance on tools
dcramer May 26, 2026
7e6ebf8
fix(prompt): Remove plugin provider knowledge
dcramer May 26, 2026
035e13e
docs(prompt): Clarify plugin guidance boundary
dcramer May 26, 2026
318cca2
feat(scheduler): Harden local scheduling flows
dcramer May 27, 2026
59a76e3
docs(plugin): Document trusted plugin hook surfaces
dcramer May 27, 2026
69c1b56
fix(dispatch): Preserve active dispatch recovery state
dcramer May 27, 2026
f88e6d5
docs(scheduler): Document built-in plugin
dcramer May 27, 2026
f8af5c6
test(evals): Add HTTP interception fixtures
dcramer May 27, 2026
dc05652
ref(scheduler): Remove legacy tick runner
dcramer May 27, 2026
6aa2634
ref(dispatch): Tighten scheduler dispatch internals
dcramer May 27, 2026
75edc7c
fix(dispatch): Harden heartbeat dispatch recovery
dcramer May 28, 2026
24e30d5
ref(plugins): Remove scheduler-specific tool flag
dcramer May 28, 2026
7cdb18e
test(evals): Remove scheduler internals from evals
dcramer May 28, 2026
06a3530
fix(dispatch): Make recovery index updates atomic
dcramer May 28, 2026
1355aca
test(evals): Reset GitHub HTTP fixtures
dcramer May 28, 2026
294a0ab
fix(logging): Clarify plugin heartbeat dispatch logs
dcramer May 28, 2026
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ dist/
packages/junior/dist
# Auto-generated by dotagents — do not commit these files.
.agents/.gitignore
# Generated by eval replay auto mode; existing tracked recordings stay tracked.
packages/junior-evals/.vitest-evals/recordings/**/*.json
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ Co-Authored-By: (agent model name) <email>
- `specs/oauth-flows-spec.md` (OAuth authorization code flow + Slack UX contract)
- `specs/agent-prompt-spec.md` (core prompt ownership, execution-bias, and bloat-control contract)
- `specs/advisor-tool-spec.md` (draft provider-agnostic advisor tool contract)
- `specs/scheduler-spec.md` (draft scheduled Junior task contract)
- `specs/harness-agent-spec.md` (agent loop and output contract)
- `specs/agent-session-resumability-spec.md` (multi-slice turn resumability and timeout recovery contract)
- `specs/agent-execution-spec.md` (agent execution rubric and completion gates)
Expand Down
3 changes: 3 additions & 0 deletions apps/example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ Copy `.env.example` and set:
- `AI_FAST_MODEL` (optional)
- `AI_VISION_MODEL` (optional, enables image-understanding; unset disables vision features)
- `AI_WEB_SEARCH_MODEL` (optional, overrides the `webSearch` tool model; defaults to a search-tuned model)
- `JUNIOR_SECRET` (required outside `pnpm dev`; the local wrapper supplies a dev-only secret when unset)
- `JUNIOR_SCHEDULER_SECRET` or `CRON_SECRET` (optional for `pnpm dev`; the local wrapper supplies a dev-only secret when both are unset)
- `NOTION_TOKEN` (optional, enables the bundled Notion plugin)

## Wiring

- `plugin-packages.ts` is the single source of truth for installed plugin packages in this app
- `nitro.config.ts` passes that list to `juniorNitro()` so plugin content is copied into the build output
- `server.ts` passes the same list to `createApp()` so local dev does not depend on Nitro's virtual config path for plugin discovery
- root `pnpm dev` starts a local heartbeat loop that calls `/api/internal/heartbeat` every minute, matching the production cron pulse used by the built-in scheduler plugin; it also defaults `JUNIOR_BASE_URL` to the local server when unset so signed internal callbacks can recover scheduled dispatches
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "node scripts/dev-with-root-env.mjs",
"dev": "node scripts/dev-server.mjs",
"dev:env": "pnpx vercel env pull .env.local --environment=development && pnpm run cloudflare:token",
"cli": "node scripts/cli-with-root-env.mjs",
"cloudflare:token": "node scripts/refresh-cloudflare-tunnel-token.mjs",
Expand All @@ -22,7 +22,7 @@
"test:watch": "pnpm --filter @sentry/junior test:watch",
"evals": "pnpm --filter @sentry/junior-evals evals",
"evals:record": "pnpm --filter @sentry/junior-evals evals:record",
"typecheck": "pnpm --filter @sentry/junior typecheck && pnpm --filter @sentry/junior-example typecheck",
"typecheck": "pnpm --filter @sentry/junior typecheck && pnpm --filter @sentry/junior-testing typecheck && pnpm --filter @sentry/junior-example typecheck",
"skills:check": "pnpm --filter @sentry/junior skills:check"
},
"simple-git-hooks": {
Expand Down
2 changes: 2 additions & 0 deletions packages/docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default defineConfig({
"/plugins/hex": "/extend/hex-plugin",
"/plugins/linear": "/extend/linear-plugin",
"/plugins/notion": "/extend/notion-plugin",
"/plugins/scheduler": "/extend/scheduler-plugin",
"/plugins/sentry": "/extend/sentry-plugin",
"/operate/telemetry-runbooks": "/operate/reliability-runbooks",
"/operate/security": "/operate/security-hardening",
Expand Down Expand Up @@ -102,6 +103,7 @@ export default defineConfig({
{ label: "Hex Plugin", link: "/extend/hex-plugin/" },
{ label: "Linear Plugin", link: "/extend/linear-plugin/" },
{ label: "Notion Plugin", link: "/extend/notion-plugin/" },
{ label: "Scheduler Plugin", link: "/extend/scheduler-plugin/" },
{ label: "Sentry Plugin", link: "/extend/sentry-plugin/" },
],
},
Expand Down
77 changes: 77 additions & 0 deletions packages/docs/src/content/docs/extend/build-a-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ installing sandbox helper files or mutating tool input/env before execution.
Trusted hooks are backend code and must be registered explicitly from app code;
Junior never loads them from `plugin.yaml`.

Trusted hook contexts include `ctx.plugin` and `ctx.log`. Use `ctx.log` for
plugin-scoped structured logs instead of writing directly to stdout.

Export a factory from the plugin package:

```ts title="index.ts"
Expand All @@ -157,6 +160,7 @@ export function myProviderPlugin() {
},
hooks: {
async sandboxPrepare(ctx) {
ctx.log.info("Preparing my-provider sandbox helpers");
await ctx.sandbox.writeFile({
path: `${ctx.sandbox.juniorRoot}/my-provider-ready`,
content: "ok\n",
Expand Down Expand Up @@ -193,6 +197,79 @@ plugin package config is merged with the build-time plugin catalog.
Use `ctx.decision.replaceInput(...)` only with object-shaped tool input. Junior
rejects non-object replacements before the tool runs.

### Trusted hook surfaces

Use the smallest hook that matches the deterministic boundary your plugin needs:

| Hook | Purpose |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| `sandboxPrepare(ctx)` | Prepare files or runtime state inside a sandbox before agent tools run. |
| `beforeToolExecute(ctx)` | Deny or rewrite object-shaped tool input and set non-secret env values before a tool runs. |
| `tools(ctx)` | Return host-registered tool definitions for the current turn. Tool names must be camelCase and cannot shadow core tools. |
| `heartbeat(ctx)` | Run bounded periodic work from Junior's internal heartbeat route. |

`tools(ctx)` receives the active turn context, `ctx.state`, and `ctx.log`.
Return tool definitions keyed by the public tool names your plugin owns:

```ts title="index.ts"
import { Type } from "@sinclair/typebox";
import { defineJuniorPlugin } from "@sentry/junior-plugin-api";

export function myProviderPlugin() {
return defineJuniorPlugin({
name: "my-provider",
hooks: {
tools(ctx) {
return {
myProviderPing: {
description: "Check my-provider connectivity.",
inputSchema: Type.Object({}),
execute: async () => {
ctx.log.info("Running my-provider ping");
return { ok: true };
},
},
};
},
},
});
}
```

`heartbeat(ctx)` is for trusted plugins that need server-side background work.
Use `ctx.state` for plugin-namespaced durable state. Use
`ctx.agent.dispatch(...)` when the heartbeat needs Junior to run an autonomous
agent task, and `ctx.agent.get(...)` to reconcile that dispatch later.

```ts title="index.ts"
import { defineJuniorPlugin } from "@sentry/junior-plugin-api";

export function myProviderPlugin() {
return defineJuniorPlugin({
name: "my-provider",
hooks: {
async heartbeat(ctx) {
const lastDispatch = await ctx.state.get<{ id: string }>(
"last-dispatch",
);
if (lastDispatch) {
const dispatch = await ctx.agent.get(lastDispatch.id);
ctx.log.info("Checked background dispatch", {
status: dispatch?.status ?? "missing",
});
}

return { dispatchCount: 0 };
},
},
});
}
```

Heartbeat dispatches are durable, signed, bounded, and scoped to the plugin
that created them. Plugins can dispatch only to validated Slack destinations
and receive projection records, not raw runtime state.

## Validate

Run validation before deploy:
Expand Down
4 changes: 4 additions & 0 deletions packages/docs/src/content/docs/extend/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ related:
- /extend/hex-plugin/
- /extend/linear-plugin/
- /extend/notion-plugin/
- /extend/scheduler-plugin/
- /extend/sentry-plugin/
---

Expand Down Expand Up @@ -56,6 +57,9 @@ For reuse across apps or teams, package plugin manifests and any bundled skills
pnpm add @sentry/junior @sentry/junior-agent-browser @sentry/junior-datadog @sentry/junior-github @sentry/junior-hex @sentry/junior-linear @sentry/junior-notion @sentry/junior-sentry
```

Junior also includes the built-in [Scheduler Plugin](/extend/scheduler-plugin/)
for reminders and recurring Slack tasks. It does not require a separate package.

List the plugin packages in `juniorNitro` so they are bundled at build time and available at runtime:

```ts title="nitro.config.ts"
Expand Down
78 changes: 78 additions & 0 deletions packages/docs/src/content/docs/extend/scheduler-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
title: Scheduler Plugin
description: Enable and verify Junior's built-in scheduled task support.
type: tutorial
summary: Configure the built-in scheduler plugin so Slack users can create reminders and recurring tasks.
prerequisites:
- /start-here/quickstart/
- /start-here/slack-app-setup/
related:
- /reference/config-and-env/
- /extend/build-a-plugin/
- /operate/reliability-runbooks/
---

The scheduler plugin is built into `@sentry/junior`. It registers Slack tools for creating, listing, updating, deleting, and running scheduled tasks, then uses Junior's internal heartbeat to dispatch due work back to the configured Slack conversation.

## Runtime setup

No plugin package install is required. `createApp()` registers the trusted scheduler plugin automatically:

```ts title="server.ts"
import { createApp } from "@sentry/junior";

const app = await createApp();

export default app;
```

The Vercel helper includes the internal heartbeat route:

```ts title="vercel.config.ts"
import { juniorVercelConfig } from "@sentry/junior/vercel";

export default juniorVercelConfig();
```

If you manage routes manually, call the heartbeat route on a one-minute cadence:

| Route | Purpose |
| ------------------------- | ------------------------------- |
| `/api/internal/heartbeat` | Runs trusted plugin heartbeats. |

## Configure environment variables

Set one scheduler route secret:

| Variable | Required | Purpose |
| ------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------- |
| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Production | Bearer token for internal scheduler and heartbeat routes. Use `CRON_SECRET` with Vercel Cron. |
| `JUNIOR_TIMEZONE` | No | Default IANA timezone for schedule authoring. Defaults to `America/Los_Angeles`. |

Local development can run without a scheduler route secret when you call the dev server directly. Production deployments should set `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET`.

## Verify

Run the workflow in Slack where users will schedule work:

```text
remind me in 1 minute to stretch
```

Then confirm:

1. Junior acknowledges the scheduled task without asking for confirmation for the simple one-off reminder.
2. `what scheduled tasks do i have` lists the task in the same Slack conversation.
3. The reminder posts back to that conversation after the due time.

For recurring or non-reminder scheduled work, Junior should show the proposed task details and wait for confirmation before creating the task.

## Failure modes

- No due tasks run: confirm `/api/internal/heartbeat` is called every minute and the route secret matches the configured bearer token.
- Tasks list but never complete: check scheduler and dispatch logs for missing Slack destination fields or stale dispatch recovery errors.
- Unexpected timezone: set `JUNIOR_TIMEZONE` to the deployment default, or include the timezone in the user's schedule request.

## Next step

Read [Build a Plugin](/extend/build-a-plugin/) for the trusted `tools(ctx)` and `heartbeat(ctx)` APIs that the built-in scheduler uses.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ title: "createApp"

> **createApp**(`options?`): `Promise`\<`Hono`\<`BlankEnv`, `BlankSchema`, `"/"`\>\>

Defined in: [app.ts:175](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L175)
Defined in: [app.ts:180](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L180)

Create a Hono app with all Junior routes.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ prev: false
title: "JuniorAppOptions"
---

Defined in: [app.ts:30](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L30)
Defined in: [app.ts:33](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L33)

## Properties

### configDefaults?

> `optional` **configDefaults?**: `Record`\<`string`, `unknown`\>

Defined in: [app.ts:32](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L32)
Defined in: [app.ts:35](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L35)

Install-wide provider defaults (`provider.key` format). Channel overrides take precedence.

Expand All @@ -23,7 +23,7 @@ Install-wide provider defaults (`provider.key` format). Channel overrides take p

> `optional` **plugins?**: `PluginConfig` \| `JuniorPlugin`[]

Defined in: [app.ts:40](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L40)
Defined in: [app.ts:43](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L43)

Plugin packages/overrides, or trusted plugin instances loaded by this app.

Expand All @@ -37,4 +37,4 @@ their package config is merged with the catalog bundled by `juniorNitro()`.

> `optional` **waitUntil?**: `WaitUntilFn`

Defined in: [app.ts:41](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L41)
Defined in: [app.ts:44](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L44)
29 changes: 16 additions & 13 deletions packages/docs/src/content/docs/reference/config-and-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,22 @@ related:

## Core runtime

| Variable | Required | Purpose |
| ------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SLACK_SIGNING_SECRET` | Yes | Verifies Slack request signatures. |
| `SLACK_BOT_TOKEN` or `SLACK_BOT_USER_TOKEN` | Yes | Posts thread replies and calls Slack APIs. |
| `REDIS_URL` | Yes | Queue and runtime state storage. |
| `JUNIOR_SECRET` | Yes | Signs internal timeout-resume callbacks and sandbox egress requester context. |
| `JUNIOR_BOT_NAME` | No | Bot display/config naming. |
| `AI_MODEL` | No | Primary model selection override for main assistant turns. Defaults to `openai/gpt-5.4`; Junior chooses the reasoning effort per turn automatically. |
| `AI_FAST_MODEL` | No | Faster model for lightweight tasks and routing/classification passes before the main turn begins. Defaults to `openai/gpt-5.4-mini`. |
| `AI_VISION_MODEL` | No | Dedicated image-understanding model; unset disables vision features. |
| `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. |
| `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. |
| `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. |
| Variable | Required | Purpose |
| ------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SLACK_SIGNING_SECRET` | Yes | Verifies Slack request signatures. |
| `SLACK_BOT_TOKEN` or `SLACK_BOT_USER_TOKEN` | Yes | Posts thread replies and calls Slack APIs. |
| `REDIS_URL` | Yes | Queue and runtime state storage. |
| `JUNIOR_SECRET` | Yes | Signs internal timeout-resume and agent-dispatch callbacks, plus sandbox egress requester context. |
| `JUNIOR_BOT_NAME` | No | Bot display/config naming. |
| `AI_MODEL` | No | Primary model selection override for main assistant turns. Defaults to `openai/gpt-5.4`; Junior chooses the reasoning effort per turn automatically. |
| `AI_FAST_MODEL` | No | Faster model for lightweight tasks and routing/classification passes before the main turn begins. Defaults to `openai/gpt-5.4-mini`. |
| `AI_VISION_MODEL` | No | Dedicated image-understanding model; unset disables vision features. |
| `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. |
| `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. |
| `JUNIOR_STATE_KEY_PREFIX` | No | Optional namespace prepended to all state-adapter keys, locks, and queues. Use separate prefixes when sharing one Redis database across environments. |
| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Conditional | Bearer token for internal scheduler and heartbeat routes; use `CRON_SECRET` with Vercel Cron, or `JUNIOR_SCHEDULER_SECRET` for an external scheduler. |
| `JUNIOR_TIMEZONE` | No | Default IANA timezone for scheduler authoring and other timezone-sensitive behavior. Defaults to `America/Los_Angeles`. |
| `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. |

Generate `JUNIOR_SECRET` with Node, then store the generated value in every environment that runs the same app:

Expand Down
Loading
Loading