@bli-cockpit/mcp 0.1.59 → 0.1.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/dist/agent-door.d.ts +12 -0
- package/dist/agent-door.js +12 -1
- package/dist/cal-tools.js +25 -22
- package/dist/careers-tools.d.ts +6 -0
- package/dist/careers-tools.js +79 -32
- package/dist/docs-msg-tools.js +109 -34
- package/dist/mail-tools.js +47 -26
- package/dist/memory-consolidation-tools.js +20 -2
- package/dist/memory-experience-tools.js +5 -1
- package/dist/models-tools.js +15 -2
- package/dist/note-folder-tools.js +11 -5
- package/dist/notes-account-tools.js +2 -2
- package/dist/notes-tools.js +11 -4
- package/dist/ops-tools.js +40 -6
- package/dist/server.d.ts +8 -2
- package/dist/server.js +27 -4
- package/dist/settings-tools.js +35 -8
- package/dist/tool-catalog.d.ts +32 -0
- package/dist/tool-catalog.js +150 -0
- package/dist/tool-result.d.ts +52 -1
- package/dist/tool-result.js +92 -2
- package/dist/usage-tools.js +40 -8
- package/dist/work-tools.js +48 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,6 +25,75 @@ dead"). `src/verb-census.test.ts` is that rule as a test — it reads the CLI's
|
|
|
25
25
|
own decision tables and fails when a verb is in neither the twin map, the
|
|
26
26
|
terminal-only list, nor the written follow-up queue.
|
|
27
27
|
|
|
28
|
+
## MCP or CLI: which one should an agent use?
|
|
29
|
+
|
|
30
|
+
Both reach the same Tower doors with the same device token and the same
|
|
31
|
+
refusal words, so the choice is about where the agent runs and what it costs.
|
|
32
|
+
|
|
33
|
+
- **The agent has a shell** (Claude Code, Codex, any terminal agent): use the
|
|
34
|
+
CLI, `cockpit <noun> <verb>`. Nothing loads into the context window until a
|
|
35
|
+
command runs, the human-readable output is the tersest form of every read,
|
|
36
|
+
and a person can run the exact same line to check it.
|
|
37
|
+
- **The agent has no shell** (claude.ai, Claude Desktop chat, IDE chat panes,
|
|
38
|
+
any connector-only host): use this server. It is the only door those hosts
|
|
39
|
+
have.
|
|
40
|
+
|
|
41
|
+
Measured on 2026-09-26 against production, same account, same moment. Bytes
|
|
42
|
+
the model actually reads; tokens are an estimate at about 4 bytes a token.
|
|
43
|
+
|
|
44
|
+
| Read | `cockpit …` text | `cockpit … --json` | MCP text half (Claude hosts) | MCP structured half (Codex) |
|
|
45
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
46
|
+
| issue list, 20 issues | 2,345 B (~0.6k tok) | 43,964 B (~11k tok) | 3,632 B (~0.9k tok) | 6,865 B (~1.7k tok) |
|
|
47
|
+
| mail inbox, 10 messages | 1,070 B (~0.3k tok) | 12,583 B (~3.1k tok) | 1,475 B (~0.4k tok) | 9,544 B (~2.4k tok) |
|
|
48
|
+
| today's brief | 2,374 B (~0.6k tok) | 4,032 B (~1k tok) | 2,262 B (~0.6k tok) | 2,635 B (~0.7k tok) |
|
|
49
|
+
|
|
50
|
+
Two costs sit outside the table. The MCP server's tool list is 109 tool
|
|
51
|
+
definitions, 94,985 bytes (~24k tokens); a host that loads every schema up
|
|
52
|
+
front pays that once per session before the first call, and a host that defers
|
|
53
|
+
schemas until a tool is searched for pays a fraction of it. The CLI's
|
|
54
|
+
equivalent is a `--help` read when the agent does not know a verb: 1,769 bytes
|
|
55
|
+
for `cockpit issue list --help`, 8,998 for the whole `cockpit --help`. And
|
|
56
|
+
`--json` is the CLI's most expensive form, because it prints the door's whole
|
|
57
|
+
answer (`issue list --json` carries every description); read the text form
|
|
58
|
+
unless a script needs the fields.
|
|
59
|
+
|
|
60
|
+
**Which half a host shows the model.** A tool result has a text half
|
|
61
|
+
(`content`) and a structured half (`structuredContent`), and hosts disagree:
|
|
62
|
+
Claude Code, claude.ai and Claude Desktop give the model the text half; Codex
|
|
63
|
+
gives it the structured half and drops the text. Every read on this server is
|
|
64
|
+
written so EACH half stands alone. That was not always true: until BLI-10432,
|
|
65
|
+
`docs_list`, `msg_channels`, `msg_read`, every `careers_*` tool,
|
|
66
|
+
`usage_people`, `usage_sessions` and `memory_consolidation` answered a
|
|
67
|
+
headline such as `5 document(s).` in text, so on a Claude host the model was
|
|
68
|
+
told the data existed and never given it. `src/tool-contract.test.ts` holds
|
|
69
|
+
the rule.
|
|
70
|
+
|
|
71
|
+
**A default read is affordable.** A list with no `limit` answers a page and
|
|
72
|
+
says when more exist: `docs_list`, `work_list_issues` and `notes_list` return
|
|
73
|
+
50 (`work_list_issues` with no limit used to return 1,000 issues with their
|
|
74
|
+
descriptions, 4.2 MB). `ops_status` returns the fleet and coverage summaries
|
|
75
|
+
and keeps the per-device and per-person rows behind `people: true` (they were
|
|
76
|
+
155 KB of a 175 KB answer). Every door gets 55 seconds, under the 60 seconds a
|
|
77
|
+
stock MCP client waits; `ops_status` and `usage_sessions` routinely take 20-35
|
|
78
|
+
seconds and used to be abandoned at 15. Each call logs one metadata line to
|
|
79
|
+
stderr, the host's MCP log: `[bli-cockpit-mcp tool] info
|
|
80
|
+
{"tool":…,"outcome":…,"ms":…,"text_bytes":…,"structured_bytes":…}`, a `warn`
|
|
81
|
+
when a half crosses 32 KB (`RESULT_BUDGET_BYTES`).
|
|
82
|
+
|
|
83
|
+
**Schemas every client compiles.** The published input schemas use only the
|
|
84
|
+
portable JSON-schema keywords (no `exclusiveMinimum`, no `propertyNames`) and
|
|
85
|
+
no lookaround in a pattern, because OpenAI's strict mode and Google's function
|
|
86
|
+
declarations compile patterns with RE2, and one refused tool can make a strict
|
|
87
|
+
host refuse the whole server. `src/tool-contract.test.ts` reads the schemas
|
|
88
|
+
from a real server over a real client and fails on either.
|
|
89
|
+
|
|
90
|
+
**Proving it answers.** `npm run mcp:smoke --workspace=@bli-cockpit/mcp`
|
|
91
|
+
calls every read and dry run in `src/tool-catalog.ts` against the Tower this
|
|
92
|
+
machine is paired with, never a write, and fails a list read over 32 KB a
|
|
93
|
+
half. `--no-model` leaves out the two reads that spend a model turn.
|
|
94
|
+
`src/tool-catalog.test.ts` fails when a registered tool is in none of the
|
|
95
|
+
catalog's read, dry-run and write lists.
|
|
96
|
+
|
|
28
97
|
## Every Tower verb, and the tool that does the same thing
|
|
29
98
|
|
|
30
99
|
The three tables below are GENERATED from `src/verb-census.ts` by
|
package/dist/agent-door.d.ts
CHANGED
|
@@ -12,6 +12,18 @@ export interface AgentDoorResponse {
|
|
|
12
12
|
/** Set only when no JSON answer was produced at all — DNS, TLS, timeout, non-JSON body. */
|
|
13
13
|
transportError: string | null;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* How long a door gets before this server gives up on it (BLI-10432).
|
|
17
|
+
*
|
|
18
|
+
* It was 15 s, and two real doors take longer than that on production:
|
|
19
|
+
* `GET /api/ops/status` (the whole board) answered in 23.5 s and
|
|
20
|
+
* `GET /api/usage/sessions?task=…` in 24 s on 2026-09-26. `cockpit` sets no
|
|
21
|
+
* deadline on either, so the CLI answered and the MCP twin reported "Tower
|
|
22
|
+
* could not be reached" for a door that was simply working. 55 s sits under
|
|
23
|
+
* the 60 s request deadline the MCP TypeScript client applies by default, so
|
|
24
|
+
* this server still answers by name before a stock client hangs up on it.
|
|
25
|
+
*/
|
|
26
|
+
export declare const DEFAULT_TIMEOUT_MS = 55000;
|
|
15
27
|
/**
|
|
16
28
|
* The four verbs Tower's agent doors answer to. `PUT` is the CLI floor
|
|
17
29
|
* (`PUT /api/settings/cli-floor`) and `DELETE` is an env blob — both arrived
|
package/dist/agent-door.js
CHANGED
|
@@ -3,7 +3,18 @@
|
|
|
3
3
|
* token (BLI-3706). Deadline-bounded; a transport failure and a door refusal
|
|
4
4
|
* are kept apart, same rule as `packages/cockpit-memory-mcp/src/door.ts`.
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* How long a door gets before this server gives up on it (BLI-10432).
|
|
8
|
+
*
|
|
9
|
+
* It was 15 s, and two real doors take longer than that on production:
|
|
10
|
+
* `GET /api/ops/status` (the whole board) answered in 23.5 s and
|
|
11
|
+
* `GET /api/usage/sessions?task=…` in 24 s on 2026-09-26. `cockpit` sets no
|
|
12
|
+
* deadline on either, so the CLI answered and the MCP twin reported "Tower
|
|
13
|
+
* could not be reached" for a door that was simply working. 55 s sits under
|
|
14
|
+
* the 60 s request deadline the MCP TypeScript client applies by default, so
|
|
15
|
+
* this server still answers by name before a stock client hangs up on it.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_TIMEOUT_MS = 55_000;
|
|
7
18
|
export async function callAgentDoor(session, fetchImpl, method, path, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
8
19
|
const url = `${session.dashboardUrl}${path}`;
|
|
9
20
|
const controller = new AbortController();
|
package/dist/cal-tools.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { z } from "zod";
|
|
33
33
|
import { callAgentDoor } from "./agent-door.js";
|
|
34
|
-
import { CONFIRM_INPUT, doorFailureText, errorResult, queryString, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
|
|
34
|
+
import { CONFIRM_INPUT, EMAIL_INPUT, doorFailureText, errorResult, queryString, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
|
|
35
35
|
/** This machine's zone: what a person means by "today" unless they say otherwise. */
|
|
36
36
|
function localZone() {
|
|
37
37
|
try {
|
|
@@ -73,9 +73,9 @@ function windowRenderer(label) {
|
|
|
73
73
|
export function registerCalTools(server, deps) {
|
|
74
74
|
const register = registrarFor(server);
|
|
75
75
|
const windowInput = {
|
|
76
|
-
time_zone: z.string().min(1).max(80).optional(),
|
|
77
|
-
calendar_id: z.string().uuid().optional(),
|
|
78
|
-
limit: z.number().int().min(1).max(750).optional(),
|
|
76
|
+
time_zone: z.string().min(1).max(80).optional().describe("An IANA zone, e.g. Asia/Saigon. Default: this machine's zone."),
|
|
77
|
+
calendar_id: z.string().uuid().optional().describe("One calendar id (cal_calendars). Omit for every calendar you may read."),
|
|
78
|
+
limit: z.number().int().min(1).max(750).optional().describe("At most this many events."),
|
|
79
79
|
};
|
|
80
80
|
function windowQuery(args) {
|
|
81
81
|
const query = new URLSearchParams({ tz: String(args.time_zone ?? localZone()) });
|
|
@@ -94,7 +94,7 @@ export function registerCalTools(server, deps) {
|
|
|
94
94
|
description: "Every event today across every calendar you may read, in the zone you name (default: this machine's). "
|
|
95
95
|
+ "`offset` moves the day — 1 is tomorrow, -1 yesterday. Recurring events are expanded for exactly this day, "
|
|
96
96
|
+ "and a series whose rule Tower will not guess at is named rather than dropped.",
|
|
97
|
-
inputSchema: { ...windowInput, offset: z.number().int().min(-52).max(52).optional() },
|
|
97
|
+
inputSchema: { ...windowInput, offset: z.number().int().min(-52).max(52).optional().describe("Days from today: 1 is tomorrow, -1 yesterday.") },
|
|
98
98
|
}, async (args) => withSession(deps, async (session) => {
|
|
99
99
|
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cal/today${queryString(windowQuery(args))}`);
|
|
100
100
|
if (!response.ok)
|
|
@@ -105,7 +105,7 @@ export function registerCalTools(server, deps) {
|
|
|
105
105
|
title: "What is on this week",
|
|
106
106
|
description: "Monday to Sunday, across every calendar you may read. The week starts on MONDAY here. `offset` moves it — 1 "
|
|
107
107
|
+ "is next week.",
|
|
108
|
-
inputSchema: { ...windowInput, offset: z.number().int().min(-52).max(52).optional() },
|
|
108
|
+
inputSchema: { ...windowInput, offset: z.number().int().min(-52).max(52).optional().describe("Weeks from this one: 1 is next week, -1 last week.") },
|
|
109
109
|
}, async (args) => withSession(deps, async (session) => {
|
|
110
110
|
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cal/week${queryString(windowQuery(args))}`);
|
|
111
111
|
if (!response.ok)
|
|
@@ -117,7 +117,7 @@ export function registerCalTools(server, deps) {
|
|
|
117
117
|
description: "What is next, counted from RIGHT NOW rather than from midnight — at 23:50 the honest answer is tomorrow's "
|
|
118
118
|
+ "09:30, and a day-shaped window would say 'nothing'. Defaults to the next 72 hours; `hours` narrows or widens "
|
|
119
119
|
+ "it. An event already running is included, so this also answers 'what am I in?'.",
|
|
120
|
-
inputSchema: { ...windowInput, hours: z.number().int().min(1).max(720).optional() },
|
|
120
|
+
inputSchema: { ...windowInput, hours: z.number().int().min(1).max(720).optional().describe("How far ahead to look, in hours. Default 72.") },
|
|
121
121
|
}, async (args) => withSession(deps, async (session) => {
|
|
122
122
|
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cal/next${queryString(windowQuery(args))}`);
|
|
123
123
|
if (!response.ok)
|
|
@@ -130,9 +130,9 @@ export function registerCalTools(server, deps) {
|
|
|
130
130
|
+ "\"when is standup\". A repeating event is answered with the NEXT time it happens, never with its first "
|
|
131
131
|
+ "occurrence two years ago.",
|
|
132
132
|
inputSchema: {
|
|
133
|
-
query: z.string().min(1).max(400),
|
|
134
|
-
calendar_id: z.string().uuid().optional(),
|
|
135
|
-
limit: z.number().int().min(1).max(100).optional(),
|
|
133
|
+
query: z.string().min(1).max(400).describe("Words to find in an event's title, location or description."),
|
|
134
|
+
calendar_id: z.string().uuid().optional().describe("One calendar id (cal_calendars). Omit for every calendar you may read."),
|
|
135
|
+
limit: z.number().int().min(1).max(100).optional().describe("At most this many matches."),
|
|
136
136
|
},
|
|
137
137
|
}, async (args) => withSession(deps, async (session) => {
|
|
138
138
|
const query = new URLSearchParams({ q: String(args.query ?? "") });
|
|
@@ -174,14 +174,14 @@ export function registerCalTools(server, deps) {
|
|
|
174
174
|
+ "says so by name. Times are ISO instants, or plain dates with all_day. Requires confirm: true, because "
|
|
175
175
|
+ "attendees are emailed by Google the moment this succeeds.",
|
|
176
176
|
inputSchema: {
|
|
177
|
-
calendar_id: z.string().uuid(),
|
|
178
|
-
title: z.string().min(1).max(500),
|
|
179
|
-
starts_at: z.string().min(4).max(40),
|
|
180
|
-
ends_at: z.string().min(4).max(40),
|
|
181
|
-
location: z.string().max(1_000).optional(),
|
|
182
|
-
all_day: z.boolean().optional(),
|
|
183
|
-
time_zone: z.string().min(1).max(80).optional(),
|
|
184
|
-
attendees: z.array(
|
|
177
|
+
calendar_id: z.string().uuid().describe("A google_oauth calendar id you own (cal_calendars)."),
|
|
178
|
+
title: z.string().min(1).max(500).describe("The event title."),
|
|
179
|
+
starts_at: z.string().min(4).max(40).describe("An ISO instant, or a YYYY-MM-DD date with all_day."),
|
|
180
|
+
ends_at: z.string().min(4).max(40).describe("An ISO instant, or a YYYY-MM-DD date with all_day."),
|
|
181
|
+
location: z.string().max(1_000).optional().describe("Where it happens."),
|
|
182
|
+
all_day: z.boolean().optional().describe("true: starts_at and ends_at are dates, not instants."),
|
|
183
|
+
time_zone: z.string().min(1).max(80).optional().describe("An IANA zone for the event. Default: this machine's zone."),
|
|
184
|
+
attendees: z.array(EMAIL_INPUT).max(100).optional().describe("Email addresses to invite. Google emails each one."),
|
|
185
185
|
meet: z.boolean().optional().describe("Attach a Google Meet room (BLI-3810). If Google refuses, the event is still created and the answer says meet_not_available."),
|
|
186
186
|
confirm: CONFIRM_INPUT,
|
|
187
187
|
},
|
|
@@ -217,8 +217,8 @@ export function registerCalTools(server, deps) {
|
|
|
217
217
|
+ "else change it — only the owner writes. Requires confirm: true: making somebody's day visible to nine people "
|
|
218
218
|
+ "is not something to do because a sentence sounded like it.",
|
|
219
219
|
inputSchema: {
|
|
220
|
-
calendar_id: z.string().uuid(),
|
|
221
|
-
org_visible: z.boolean(),
|
|
220
|
+
calendar_id: z.string().uuid().describe("A calendar id you own (cal_calendars)."),
|
|
221
|
+
org_visible: z.boolean().describe("true: everyone at the org may read it. false: private again."),
|
|
222
222
|
confirm: CONFIRM_INPUT,
|
|
223
223
|
},
|
|
224
224
|
}, async (args) => withSession(deps, async (session) => {
|
|
@@ -241,7 +241,10 @@ export function registerCalTools(server, deps) {
|
|
|
241
241
|
description: "Runs one sync pass for a calendar you OWN, instead of waiting for the 15-minute cron. `full: true` ignores the "
|
|
242
242
|
+ "sync token / ETag and re-reads everything. The answer is the run's own counts and reason label, which is "
|
|
243
243
|
+ "exactly what the ledger row says.",
|
|
244
|
-
inputSchema: {
|
|
244
|
+
inputSchema: {
|
|
245
|
+
calendar_id: z.string().uuid().describe("A calendar id you own (cal_calendars)."),
|
|
246
|
+
full: z.boolean().optional().describe("true: ignore the sync token and re-read everything."),
|
|
247
|
+
},
|
|
245
248
|
}, async (args) => withSession(deps, async (session) => {
|
|
246
249
|
const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/cal/calendars/${encodeURIComponent(String(args.calendar_id))}/sync${args.full === true ? "?full=1" : ""}`);
|
|
247
250
|
if (!response.ok)
|
|
@@ -259,7 +262,7 @@ export function registerCalTools(server, deps) {
|
|
|
259
262
|
title: "Detach a calendar",
|
|
260
263
|
description: "Removes a calendar and its stored events from Tower. The calendar itself is untouched at Google — this only "
|
|
261
264
|
+ "forgets it here. Requires confirm: true.",
|
|
262
|
-
inputSchema: { calendar_id: z.string().uuid(), confirm: CONFIRM_INPUT },
|
|
265
|
+
inputSchema: { calendar_id: z.string().uuid().describe("A calendar id you own (cal_calendars)."), confirm: CONFIRM_INPUT },
|
|
263
266
|
}, async (args) => withSession(deps, async (session) => {
|
|
264
267
|
const refusal = unconfirmed(args, "This would detach that calendar and forget its stored events (the calendar itself is untouched at Google).");
|
|
265
268
|
if (refusal)
|
package/dist/careers-tools.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { type ToolDeps } from './tool-result.js';
|
|
2
2
|
/**
|
|
3
|
+
* Every result here is a `dataResult`: the door's body rides in the text half
|
|
4
|
+
* as well as the structured one. These tools used to answer with one word
|
|
5
|
+
* ("Applications", "Resume link") in text, so on a Claude-family client, which
|
|
6
|
+
* shows the model only the text, the resume URL and every applicant were
|
|
7
|
+
* invisible (BLI-10432).
|
|
8
|
+
*
|
|
3
9
|
* `careers_*` — the MCP twins of `cockpit careers`, over the same doors.
|
|
4
10
|
*
|
|
5
11
|
* BLI-4460 added the pipeline half: inviting, deciding, the role's take-home
|
package/dist/careers-tools.js
CHANGED
|
@@ -1,7 +1,32 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { callAgentDoor } from './agent-door.js';
|
|
3
|
-
import { doorFailureText, errorResult, registrarFor,
|
|
3
|
+
import { dataResult, doorFailureText, errorResult, registrarFor, withSession } from './tool-result.js';
|
|
4
|
+
/** Verbatim from `lib/careers/applications.ts` `PIPELINE_STAGES`. */
|
|
5
|
+
const PIPELINE_STAGES = ['screened', 'invited', 'submitted', 'graded', 'booking_sent', 'passed_on', 'archived'];
|
|
6
|
+
const APPLICATION_ID = z.string().uuid().describe('An application id, as careers_list returns it.');
|
|
4
7
|
/**
|
|
8
|
+
* One list row without the screening's prose (BLI-10432). The door returns the
|
|
9
|
+
* whole screening object per row, summary sentences and quoted evidence
|
|
10
|
+
* included: about 2 KB an applicant, so the door's own 100-row page is ~200 KB.
|
|
11
|
+
* The list keeps what triage sorts on; careers_show reads the rest.
|
|
12
|
+
*/
|
|
13
|
+
function applicationListRow(row) {
|
|
14
|
+
const screening = row.screening && typeof row.screening === 'object' ? row.screening : {};
|
|
15
|
+
const { screening: _screening, ...rest } = row;
|
|
16
|
+
return {
|
|
17
|
+
...rest,
|
|
18
|
+
score: typeof screening.score === 'number' ? screening.score : null,
|
|
19
|
+
recommend: typeof screening.recommend === 'string' ? screening.recommend : null,
|
|
20
|
+
flags: Array.isArray(screening.flags) ? screening.flags : [],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Every result here is a `dataResult`: the door's body rides in the text half
|
|
25
|
+
* as well as the structured one. These tools used to answer with one word
|
|
26
|
+
* ("Applications", "Resume link") in text, so on a Claude-family client, which
|
|
27
|
+
* shows the model only the text, the resume URL and every applicant were
|
|
28
|
+
* invisible (BLI-10432).
|
|
29
|
+
*
|
|
5
30
|
* `careers_*` — the MCP twins of `cockpit careers`, over the same doors.
|
|
6
31
|
*
|
|
7
32
|
* BLI-4460 added the pipeline half: inviting, deciding, the role's take-home
|
|
@@ -17,18 +42,40 @@ import { doorFailureText, errorResult, registrarFor, textResult, withSession } f
|
|
|
17
42
|
*/
|
|
18
43
|
export function registerCareersTools(server, deps) {
|
|
19
44
|
const register = registrarFor(server);
|
|
20
|
-
register('careers_list', {
|
|
45
|
+
register('careers_list', {
|
|
46
|
+
title: 'List applications',
|
|
47
|
+
description: 'Super-admin only. Newest applications first: id, name, role, stage, screening score, recommendation and flags, with total and has_more, plus the two thresholds in force. Page with limit (default 100, max 200) and offset. The screening prose is not in the list; careers_show reads one application in full.',
|
|
48
|
+
inputSchema: {
|
|
49
|
+
role: z.string().min(1).max(200).optional().describe('A role slug, e.g. fde-intern-2027 (the role_slug careers_list returns).'),
|
|
50
|
+
min_score: z.number().min(0).max(100).optional().describe('Only applications whose screening scored at least this (0-100).'),
|
|
51
|
+
since: z.string().min(1).max(40).optional().describe('ISO-8601 instant with an offset, e.g. 2026-09-01T00:00:00Z: applied at or after.'),
|
|
52
|
+
until: z.string().min(1).max(40).optional().describe('ISO-8601 instant with an offset: applied before.'),
|
|
53
|
+
stage: z.enum(PIPELINE_STAGES).optional().describe('Only applications at this pipeline stage.'),
|
|
54
|
+
limit: z.number().int().min(1).max(200).optional().describe('At most this many rows. Default 100.'),
|
|
55
|
+
offset: z.number().int().min(0).max(100_000).optional().describe('Skip this many rows first, for the next page.'),
|
|
56
|
+
},
|
|
57
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
21
58
|
const query = new URLSearchParams();
|
|
22
|
-
for (const key of ['role', 'min_score', 'since', 'stage'])
|
|
59
|
+
for (const key of ['role', 'min_score', 'since', 'until', 'stage', 'limit', 'offset'])
|
|
23
60
|
if (args[key] !== undefined)
|
|
24
61
|
query.set(key, String(args[key]));
|
|
25
62
|
const answer = await callAgentDoor(session, deps.fetchImpl, 'GET', `/api/careers/applications?${query}`);
|
|
26
|
-
|
|
63
|
+
if (!answer.ok)
|
|
64
|
+
return errorResult(doorFailureText('careers_list', answer));
|
|
65
|
+
const rows = (Array.isArray(answer.body.applications) ? answer.body.applications : []);
|
|
66
|
+
const body = { ...answer.body, applications: rows.map(applicationListRow) };
|
|
67
|
+
return dataResult(`${rows.length} of ${String(answer.body.total ?? rows.length)} application(s)${answer.body.has_more === true ? ', more with offset' : ''}.`, body);
|
|
27
68
|
}));
|
|
28
69
|
for (const action of ['show', 'rescreen'])
|
|
29
|
-
register(`careers_${action}`, {
|
|
70
|
+
register(`careers_${action}`, {
|
|
71
|
+
title: action === 'show' ? 'Show one application' : 'Screen one application again',
|
|
72
|
+
description: action === 'show'
|
|
73
|
+
? 'Super-admin only. One application in full: the answers, the screening (score, summary, quoted evidence, flags), stage and grade.'
|
|
74
|
+
: 'Super-admin only. Runs the screening again for one application and stores the new result. Spends one model call.',
|
|
75
|
+
inputSchema: { id: APPLICATION_ID },
|
|
76
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
30
77
|
const answer = await callAgentDoor(session, deps.fetchImpl, action === 'show' ? 'GET' : 'POST', `/api/careers/applications/${encodeURIComponent(String(args.id))}${action === 'rescreen' ? '/rescreen' : ''}`, undefined, 60_000);
|
|
31
|
-
return answer.ok ?
|
|
78
|
+
return answer.ok ? dataResult(action === 'show' ? 'Application' : 'Screened again', answer.body) : errorResult(doorFailureText(`careers_${action}`, answer));
|
|
32
79
|
}));
|
|
33
80
|
// Two names, ONE door (BLI-4460 follow-up). Tower's button says "Send
|
|
34
81
|
// take-home" and this tool said `invite`, so an agent reading the screen and
|
|
@@ -43,18 +90,18 @@ export function registerCareersTools(server, deps) {
|
|
|
43
90
|
register(tool, {
|
|
44
91
|
title: 'Send the take-home',
|
|
45
92
|
description,
|
|
46
|
-
inputSchema: { id:
|
|
93
|
+
inputSchema: { id: APPLICATION_ID },
|
|
47
94
|
}, async (args) => withSession(deps, async (session) => {
|
|
48
95
|
const answer = await callAgentDoor(session, deps.fetchImpl, 'POST', `/api/careers/applications/${encodeURIComponent(String(args.id))}/invite`, undefined, 60_000);
|
|
49
|
-
return answer.ok ?
|
|
96
|
+
return answer.ok ? dataResult('Invitation sent', answer.body) : errorResult(doorFailureText(tool, answer));
|
|
50
97
|
}));
|
|
51
98
|
register('careers_grade', {
|
|
52
99
|
title: 'Re-queue a take-home for grading',
|
|
53
|
-
description: 'Super-admin only. Puts one submitted or graded application back in the take-home grader\'s queue. The grader runs on Railway every
|
|
54
|
-
inputSchema: { id:
|
|
100
|
+
description: 'Super-admin only. Puts one submitted or graded application back in the take-home grader\'s queue. The grader runs on Railway every 5 minutes because it clones a repository and reads a video; `now` is answered honestly rather than run here.',
|
|
101
|
+
inputSchema: { id: APPLICATION_ID, now: z.boolean().optional().describe('Ask for an immediate grade. Answered honestly: the grader only runs on its Railway schedule.') },
|
|
55
102
|
}, async (args) => withSession(deps, async (session) => {
|
|
56
103
|
const answer = await callAgentDoor(session, deps.fetchImpl, 'POST', `/api/careers/applications/${encodeURIComponent(String(args.id))}/grade`, { now: args.now === true }, 60_000);
|
|
57
|
-
return answer.ok ?
|
|
104
|
+
return answer.ok ? dataResult('Queued for grading', answer.body) : errorResult(doorFailureText('careers_grade', answer));
|
|
58
105
|
}));
|
|
59
106
|
// BLI-4462. The drawer has an "Open resume" button and this surface had no
|
|
60
107
|
// word for it, so an agent could read every screening line about a candidate
|
|
@@ -65,7 +112,7 @@ export function registerCareersTools(server, deps) {
|
|
|
65
112
|
register('careers_resume_url', {
|
|
66
113
|
title: 'Link to a resume',
|
|
67
114
|
description: 'Super-admin only. A signed link to one applicant\'s resume file, the same one the Open resume button uses. THE LINK DIES IN 60 SECONDS: fetch it and read it in the same turn, never store it. An application with no resume on file is refused rather than answered with an empty link.',
|
|
68
|
-
inputSchema: { id:
|
|
115
|
+
inputSchema: { id: APPLICATION_ID },
|
|
69
116
|
}, async (args) => withSession(deps, async (session) => {
|
|
70
117
|
const answer = await callAgentDoor(session, deps.fetchImpl, 'GET', `/api/careers/applications/${encodeURIComponent(String(args.id))}/resume-url`);
|
|
71
118
|
if (!answer.ok)
|
|
@@ -74,39 +121,39 @@ export function registerCareersTools(server, deps) {
|
|
|
74
121
|
// `expires_in_seconds`, because 60 with no unit reads as minutes.
|
|
75
122
|
const body = (answer.body ?? {});
|
|
76
123
|
const expires = typeof body.expires_in === 'number' && body.expires_in > 0 ? body.expires_in : 60;
|
|
77
|
-
return
|
|
124
|
+
return dataResult(`Resume link, valid for ${expires} seconds:`, { url: body.url, expires_in_seconds: expires });
|
|
78
125
|
}));
|
|
79
126
|
register('careers_decide', {
|
|
80
127
|
title: 'Decide on an application',
|
|
81
128
|
description: 'Super-admin only. Records the call on one application: `passed_on`, `archived`, or `reopen` to put it back at `screened`.',
|
|
82
|
-
inputSchema: { id:
|
|
129
|
+
inputSchema: { id: APPLICATION_ID, decision: z.enum(['passed_on', 'archived', 'reopen']).describe('passed_on, archived, or reopen (back to screened).') },
|
|
83
130
|
}, async (args) => withSession(deps, async (session) => {
|
|
84
131
|
const answer = await callAgentDoor(session, deps.fetchImpl, 'POST', `/api/careers/applications/${encodeURIComponent(String(args.id))}/decide`, { decision: args.decision });
|
|
85
|
-
return answer.ok ?
|
|
132
|
+
return answer.ok ? dataResult('Decision recorded', answer.body) : errorResult(doorFailureText('careers_decide', answer));
|
|
86
133
|
}));
|
|
87
134
|
register('careers_takehome_show', {
|
|
88
135
|
title: 'Show a role take-home',
|
|
89
136
|
description: 'Super-admin only. The subject, body, artifact link, reviewer rubric and pass score for one role. The rubric is reviewer material and never reaches a candidate.',
|
|
90
|
-
inputSchema: { role: z.string().min(1).max(200) },
|
|
137
|
+
inputSchema: { role: z.string().min(1).max(200).describe('A role slug, e.g. fde-intern-2027 (the role_slug careers_list returns).') },
|
|
91
138
|
}, async (args) => withSession(deps, async (session) => {
|
|
92
139
|
const answer = await callAgentDoor(session, deps.fetchImpl, 'GET', `/api/careers/takehomes/${encodeURIComponent(String(args.role))}`);
|
|
93
|
-
return answer.ok ?
|
|
140
|
+
return answer.ok ? dataResult('Take-home', answer.body) : errorResult(doorFailureText('careers_takehome_show', answer));
|
|
94
141
|
}));
|
|
95
142
|
register('careers_takehome_set', {
|
|
96
143
|
title: 'Edit a role take-home',
|
|
97
144
|
description: 'Super-admin only. Edits one role\'s take-home. A body must keep the {first_name} and {link} placeholders. A role nobody seeded is refused rather than created.',
|
|
98
145
|
inputSchema: {
|
|
99
|
-
role: z.string().min(1).max(200),
|
|
100
|
-
subject: z.string().min(1).max(998).optional(),
|
|
101
|
-
body_text: z.string().min(1).max(100_000).optional(),
|
|
102
|
-
artifact_link: z.string().url().max(2_000).optional(),
|
|
103
|
-
rubric_md: z.string().min(1).max(100_000).optional(),
|
|
104
|
-
pass_score: z.number().min(0).max(10).optional(),
|
|
146
|
+
role: z.string().min(1).max(200).describe('A role slug, e.g. fde-intern-2027.'),
|
|
147
|
+
subject: z.string().min(1).max(998).optional().describe('The invitation email subject.'),
|
|
148
|
+
body_text: z.string().min(1).max(100_000).optional().describe('The invitation body. Must keep the {first_name} and {link} placeholders.'),
|
|
149
|
+
artifact_link: z.string().url().max(2_000).optional().describe('A link to the take-home brief or starter repository.'),
|
|
150
|
+
rubric_md: z.string().min(1).max(100_000).optional().describe('The reviewer rubric, markdown. Never sent to a candidate.'),
|
|
151
|
+
pass_score: z.number().min(0).max(10).optional().describe('The grade (0-10) that passes.'),
|
|
105
152
|
},
|
|
106
153
|
}, async (args) => withSession(deps, async (session) => {
|
|
107
154
|
const { role, ...patch } = args;
|
|
108
155
|
const answer = await callAgentDoor(session, deps.fetchImpl, 'PUT', `/api/careers/takehomes/${encodeURIComponent(String(role))}`, patch);
|
|
109
|
-
return answer.ok ?
|
|
156
|
+
return answer.ok ? dataResult('Take-home saved', answer.body) : errorResult(doorFailureText('careers_takehome_set', answer));
|
|
110
157
|
}));
|
|
111
158
|
register('careers_settings_show', {
|
|
112
159
|
title: 'Show the careers settings',
|
|
@@ -114,21 +161,21 @@ export function registerCareersTools(server, deps) {
|
|
|
114
161
|
inputSchema: {},
|
|
115
162
|
}, async () => withSession(deps, async (session) => {
|
|
116
163
|
const answer = await callAgentDoor(session, deps.fetchImpl, 'GET', '/api/careers/settings');
|
|
117
|
-
return answer.ok ?
|
|
164
|
+
return answer.ok ? dataResult('Careers settings', answer.body) : errorResult(doorFailureText('careers_settings_show', answer));
|
|
118
165
|
}));
|
|
119
166
|
register('careers_settings_set', {
|
|
120
167
|
title: 'Change the careers settings',
|
|
121
168
|
description: 'Super-admin only. Writes any of the four careers settings. Each takes effect on the next screening, with no deploy.',
|
|
122
169
|
inputSchema: {
|
|
123
|
-
auto_invite_enabled: z.boolean().optional(),
|
|
124
|
-
auto_invite_min_score: z.number().min(0).max(100).optional(),
|
|
125
|
-
attention_min_score: z.number().min(0).max(100).optional(),
|
|
126
|
-
invite_account_id: z.string().uuid().nullable().optional(),
|
|
127
|
-
booking_link: z.string().url().max(2000).nullable().optional(),
|
|
128
|
-
grader_model: z.string().min(3).max(200).optional(),
|
|
170
|
+
auto_invite_enabled: z.boolean().optional().describe('Send the take-home automatically to applicants over the auto-invite bar.'),
|
|
171
|
+
auto_invite_min_score: z.number().min(0).max(100).optional().describe('The screening score (0-100) at or over which the take-home goes out automatically.'),
|
|
172
|
+
attention_min_score: z.number().min(0).max(100).optional().describe('The screening score (0-100) at or over which an applicant is flagged for attention.'),
|
|
173
|
+
invite_account_id: z.string().uuid().nullable().optional().describe('The mailbox id (mail_accounts) invitations are sent from; null clears it.'),
|
|
174
|
+
booking_link: z.string().url().max(2000).nullable().optional().describe('The interview booking link sent after a passing grade; null clears it.'),
|
|
175
|
+
grader_model: z.string().min(3).max(200).optional().describe('The take-home grader model, provider:model (models_list ids).'),
|
|
129
176
|
},
|
|
130
177
|
}, async (args) => withSession(deps, async (session) => {
|
|
131
178
|
const answer = await callAgentDoor(session, deps.fetchImpl, 'PUT', '/api/careers/settings', args);
|
|
132
|
-
return answer.ok ?
|
|
179
|
+
return answer.ok ? dataResult('Careers settings saved', answer.body) : errorResult(doorFailureText('careers_settings_set', answer));
|
|
133
180
|
}));
|
|
134
181
|
}
|