{"name":"Gaston","description":"The Conductor — user-facing tutor for Xmmersia. In Training Hub, orchestrates Le Marteau (worksheet generation), Le Veilleur (progress tracking), and Lumière (grading) for personalized practice across derivatives, partial derivatives, algebra, graphing, and the Economics Setup training. In L'Étude, serves as the course-grounded text tutor (stateless tutor_answer skill over a Portal-built corpus). Also provides report discussion (Le Bilan), briefing generation, and class-broadcast composition.","url":"https://gaston.xmmersia.com","version":"3.85.0","protocolVersion":"0.2.5","documentationUrl":"https://github.com/Ayahualulco/Xmmersia-GASTON","provider":{"organization":"Xmmersia","url":"https://xmmersia.com"},"capabilities":{"streaming":false,"pushNotifications":false,"stateTransitionHistory":false,"extensions":[{"uri":"https://xmmersia.com/extensions/education/v1","description":"Educational tutoring and orchestration","required":true},{"uri":"https://xmmersia.com/extensions/llm/v1","description":"LLM-powered conversational interface","required":true}]},"defaultInputModes":["application/json"],"defaultOutputModes":["application/json"],"skills":[{"id":"chatbot","name":"Chat with Gaston","description":"LLM-powered conversation about progress, insights, and class performance. Uses GPT-4o-mini to provide intelligent responses based on Le Veilleur data. Does NOT accept an offering_id and reads the pinned default offering only — deliberately: it grounds every answer in Le Veilleur's get_class_overview, which takes no offering parameter in v5.59.0, so threading only the conditional get_progress read would build one answer out of two offerings' trees with nothing marking the seam. Blocked on Le Veilleur; do not send an offering_id, it would be discarded.","tags":["chat","llm","insights","offering-blind"],"examples":["{\"skill\": \"chatbot\", \"parameters\": {\"message\": \"How is the class doing on chain rule?\"}}","{\"skill\": \"chatbot\", \"parameters\": {\"message\": \"Which students need the most help?\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"discuss_reports","name":"Discuss Assessment Reports","description":"Student-facing conversation about Lucie assessment reports. Gaston as analyst with access to real Gradescope feedback. Fetches reports from Le Veilleur and provides grounded, helpful responses. Accepts an optional offering_id, forwarded to its single Le Veilleur call (get_reports) and folded into the 5-minute report cache key — keying the cache on computing_id alone would serve one offering's reports for another's request inside the TTL, which no amount of correct threading on the call itself would prevent. Omitted or null resolves against the pinned default offering.","tags":["chat","llm","student","reports","lucie","le-bilan","feedback","offering-aware"],"examples":["{\"skill\": \"discuss_reports\", \"parameters\": {\"computing_id\": \"abc123\", \"message\": \"What did I get wrong on Lecture 01 Quiz?\", \"offering_id\": \"1\"}}","{\"skill\": \"discuss_reports\", \"parameters\": {\"computing_id\": \"abc123\", \"message\": \"Can you explain opportunity cost?\", \"conversation_id\": \"abc123-bilan\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"generate_briefing","name":"Generate Briefing","description":"Generate a two-section Lucie report briefing (Strengths / Focus Areas). Takes pre-fetched reports, progress, and optional previous_briefing as input; returns structured briefing dict. Used by Le Veilleur's async task worker to keep briefing generation off the Vercel serverless path.","tags":["analyst","briefing","bureau","le-bilan","gemini"],"examples":["Generate a briefing for student efg3vv with their reports and progress"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"request_worksheet","name":"Request Practice Worksheet","description":"Orchestrate personalized worksheet generation. Gets student progress from Le Veilleur, requests worksheet from Le Marteau, logs the session with PDF storage for re-download. Relays the optional topic param to Le Marteau (derivatives, partial_derivatives, algebra — solve-for-x + simplify-by-canceling — since v3.49.0 graphing, and since v3.84.0 setup); when omitted, Le Marteau's default applies. SETUP (v3.84.0) is the fourth typed topic: topic=setup logs as training_type setup_worksheet, and Le Marteau mints its session id as T-{term}-Economics-Setup-{seed}-{timestamp}, passed through unchanged. It takes the typed path — generate_worksheet, then render_pdf, whose request carries each problem record verbatim with its topic, so Le Marteau lays the sheet out in one column — and it joins partials and algebra in forcing focus_topics=[] (the weak-topic machinery knows derivative rules only) while the difficulty distribution is still sent. Its stored key carries the nine-field core plus hint, traps and decoys — decoys joining the conditional passthroughs in v3.84.0, present only when the record has one, so every other topic's key is byte-identical to v3.83.0 — which is what Le Marteau's model-free Setup grader turns into authored feedback. GRAPHING TAKES A DIFFERENT GENERATION SKILL AND NO SECOND PDF HOP: topic=graphing routes to Le Marteau's generate_graphing_worksheet, which mints the same T-{term}-Math-Graphing-{seed}-{ts} worksheet id the rest of the family uses, renders its problems PDF in-process (reportlab, not LaTeX) and returns pdf_base64 / pdf_filename on the response, so render_pdf is not called. No difficulty distribution and no focus_topics are sent — the graphing ladder is authored in Le Marteau — and no count, a graphing worksheet being exactly ten. THE STORED ANSWER KEY IS REBUILT FROM A PER-TRAINING-TYPE ALLOWLIST (v3.49.0). Derivatives, partials and algebra keep the nine-field core plus the four conditional passthroughs byte for byte; a graphing entry is rebuilt instead from number / kind / level / template_id / grid / sub_answers (+ shape_label), with each sub-answer's authored value keys and rule tags carried verbatim because Le Marteau's grader reads them by name — and it is built from the response's answer_key, not from problems[], which on this path is the sheet with the answers taken off. The stored entry also gains problem_text and axes, joined on by number from that sheet: Le Veilleur serves the online drawing surface FROM the stored key and its graphing allowlist already named both, so without this join the modal opens on a grid with no question on it. The session id passes through unchanged — Gaston mints none and rewrites none. Refuses with pending_session_exists if the student already has a non-expired pending session (one-pending-worksheet rule). Optional switch_from_session_id (Brief seq 871 B) converts an open worksheet to a different type at no cost: generates the new type first, then closes the old session via close_session_for_switch, then logs the successor with the inherited deadline — so a generation failure leaves the student exactly where they started. Refuses with switch_target_mismatch (the pending session isn't the one named), switch_same_type (the requested type equals the open one — a reroll, not a switch), already_switched / not_pending / already_expired (passed through from Le Veilleur), or switch_close_failed (close transport error). Accepts an optional offering_id and forwards it to all four Le Veilleur calls this orchestration makes — check_pending (the pending guard), get_progress (the adaptive input), close_session_for_switch (switch path only) and log_session (the write). Threading is all-or-nothing by design: the guard and the write must resolve the same offering's tree, or the one-pending-worksheet rule is enforced against a cohort the student is not in and they end up with two live sessions. Omitted or null resolves against Le Veilleur's pinned default offering, byte-identical to prior behavior. The two Le Marteau calls carry no offering — Le Marteau reaches no storage. Accepts an optional focus_override (v3.39.0) that hands the decision about what the worksheet leans toward to the caller, in place of the silent weak-topic steering Gaston has applied to every derivatives request. Three modes. ABSENT: today's behavior exactly — Gaston reads the student's weak topics out of get_progress and sends them to Le Marteau as focus_topics without telling anyone; this remains the sanctioned interim for every flow that does not prompt and for every caller older than v3.39.0, and the path is byte-identical to v3.38.0 down to the params dict Le Marteau receives. {\"mode\": \"mix\"}: force focus_topics=[], a plain balanced sheet — the computed weak topics are discarded, not quietly reapplied. {\"mode\": \"work\", \"topics\": [...]}: the named tags are validated against the derivative rule vocabulary (power, chain, product, quotient, ln, exp, sum, arbitrary_base — the same tags Le Marteau stamps on problems and Le Veilleur aggregates into progress[\"topics\"], which is also what the weak-topic computation reads) and the survivors become focus_topics. VALIDATION NEVER FAILS A GENERATION: unknown or malformed tags are dropped, a focus_override that is not a dict or names another mode is ignored entirely, and if nothing survives Gaston falls back to its own computation rather than refusing — a caller's typo must not cost a student their worksheet. The override is applied AFTER the computation (so the fallback has something to fall back to) and BEFORE the partials/algebra force of focus_topics=[] (so those topics, which carry no derivative-rule signal, keep their empty focus no matter what the caller asks for). Callers must only name a topic they have already shown the student: Gaston honors the tag it is given and does not re-derive it, so the dialog and the sheet agree only if the caller makes them agree. THE STORED ANSWER KEY (v3.47.0): the session logged to Le Veilleur carries a whitelisted answer-key entry per problem — nine core fields (number, function, function_latex, answer, answer_forms, answer_latex, difficulty, rules, warnings) plus conditional passthroughs present only when Le Marteau's record had them: subtopic, simplify_method, hint, traps, and since v3.84.0 decoys. Those are what Le Marteau's grade_typed_submission reads back off the stored key, and subtopic is REQUIRED on every algebra entry (Le Marteau v4.33.0) — an entry without one fails the whole grading request with algebra_answer_key_missing_subtopic. It stays a whitelist so the bank identity fields Le Marteau attaches (id, archetype_id, variant_id, family, level, symbol_set) never reach a stored session; a derivatives or partials entry carries none of the four and is byte-identical to v3.46.0.","tags":["practice","worksheet","generation","orchestration","student","pdf","offering-aware","focus-override","graphing"],"examples":["{\"skill\": \"request_worksheet\", \"parameters\": {\"student_id\": \"abc123\"}}","{\"skill\": \"request_worksheet\", \"parameters\": {\"student_id\": \"abc123\", \"problem_count\": 15}}","{\"skill\": \"request_worksheet\", \"parameters\": {\"student_id\": \"abc123\", \"offering_id\": \"1\"}}","{\"skill\": \"request_worksheet\", \"parameters\": {\"student_id\": \"abc123\", \"topic\": \"derivatives\", \"focus_override\": {\"mode\": \"work\", \"topics\": [\"quotient\"]}, \"offering_id\": \"1\"}}","{\"skill\": \"request_worksheet\", \"parameters\": {\"student_id\": \"abc123\", \"topic\": \"derivatives\", \"focus_override\": {\"mode\": \"mix\"}, \"offering_id\": \"1\"}}","{\"skill\": \"request_worksheet\", \"parameters\": {\"student_id\": \"abc123\", \"topic\": \"partial_derivatives\", \"switch_from_session_id\": \"T-26Sp-Math-Derivatives-847291-1710432000\", \"offering_id\": \"1\"}}","{\"skill\": \"request_worksheet\", \"parameters\": {\"student_id\": \"abc123\", \"topic\": \"graphing\", \"offering_id\": \"Econ3010-26F\"}}"],"inputModes":["application/json"],"outputModes":["application/json","application/pdf"]},{"id":"get_worksheet","name":"Re-download Worksheet","description":"Retrieve a worksheet PDF for re-download. If no session_id provided, returns the pending worksheet. Used when student needs to re-download their worksheet. Accepts an optional offering_id, forwarded to its single Le Veilleur call (get_worksheet) so the PDF is fetched from the offering the session lives in. Omitted or null resolves against the pinned default offering.","tags":["worksheet","pdf","download","retrieval","student","offering-aware"],"examples":["{\"skill\": \"get_worksheet\", \"parameters\": {\"student_id\": \"abc123\"}}","{\"skill\": \"get_worksheet\", \"parameters\": {\"student_id\": \"abc123\", \"session_id\": \"WS-26Sp-123456-1234567890\", \"offering_id\": \"1\"}}"],"inputModes":["application/json"],"outputModes":["application/json","application/pdf"]},{"id":"get_worksheet_problems","name":"Get Worksheet Problems","description":"Retrieve the problems list for a worksheet session (online worksheet modal). Forwards to Le Veilleur; strips grading-side fields (answer, answer_forms, answer_latex). Passes error codes through. Accepts an optional offering_id, forwarded to its single Le Veilleur call so the modal reads the problems from the same offering's tree the session was written into — pass the same offering here that request_worksheet was given, or the modal opens empty. Omitted or null resolves against the pinned default offering.","tags":["worksheet","problems","online","modal","retrieval","student","offering-aware"],"examples":["{\"skill\": \"get_worksheet_problems\", \"parameters\": {\"student_id\": \"abc123\", \"session_id\": \"WS-26Sp-123456-1234567890\", \"offering_id\": \"1\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"grade_typed_submission","name":"Grade Typed Submission","description":"Conductor wrapper for typed worksheet grading. Since v3.40.0 a pre-grade status guard runs FIRST, ahead of the answer-key fetch: unless the caller passes allow_regrade, Gaston reads Le Veilleur's get_session_status for this student/session and refuses any session that is not pending — error_code session_expired for an expired session, session_already_graded for anything else — returning {success: false, error_code, message, session_id, logged: false} without fetching the key, without calling Le Marteau and without logging. This closes a replay: Portal's submit route validates only the session-id format and get_answer_key returns the key for a session in any status, so a student who had already submitted once (and had therefore seen every correct answer in review mode) could re-POST the same session_id with the perfect set and have it graded and logged again. allow_regrade is the one authorized bypass, set only by Le Veilleur's server-side staff-regrade worker, whose payload is otherwise key-identical to a student submit and which is by definition a re-log of a completed session; Le Veilleur's own log_result guard opens on the same flag, so a staff regrade passes both and a replay passes neither. If get_session_status fails, raises or answers ambiguously (success not true, exists false, or no usable status) the guard FAILS OPEN and grades, deliberately: refusing a genuine first submit on a storage blip loses work that exists nowhere else and is unrecoverable, whereas a replay slipping through during that window is narrow, caught downstream by Le Veilleur's log_result guard, and recoverable via recalculate_profile. Note the student-visible consequence: an expired session is now cleanly refused rather than silently double-counted. Once the guard passes, the path is unchanged. It fetches the stored authoritative answer_key from Le Veilleur's get_answer_key skill (Brief F-AnswerKey — replaces Le Marteau's previous seed-regeneration approach, which produced mismatched problems). Then calls Le Marteau's grade_typed_submission with the fetched answer_key (per-problem grading with parallel Claude pedagogical feedback for incorrect answers). Finally logs the graded session via Le Veilleur's log_result (with modality + training_type metadata). Returns the canonical per-problem grading result unchanged, plus `logged` (bool) and an optional `logging_error` when the audit write failed but the grade is still returned. If the stored answer_key cannot be fetched (no_answer_key for pre-v2.4.0 sessions, session_not_found, transport failure), grading hard-fails with a structured error — no silent fallback to regeneration. On a log_result failure (A2A error on the wire or an explicit success=False reply), verifies the write actually failed by calling Le Veilleur's get_session_status with a short bounded retry (0.5s/1.0s/2.0s) before reporting logged=false — under Box latency the ack is frequently lost in transit while the session is fully saved, so verification prevents Portal from surfacing a misleading 'couldn't save your record' banner on a save that landed. Accepts an optional offering_id and forwards it to all three Le Veilleur skills it calls — get_answer_key (the key is read from that offering's tree), log_result (the graded session is written to it) and get_session_status, which is called on two separate errands, the v3.40.0 pre-grade guard and the ack-loss verification, both threaded. All of them or none: a regrade whose key came from one offering's tree and whose write landed in another's would look successful and be wrong; a verification aimed at the default tree after a non-default write would report a false 'couldn't save your record' on a save that landed; and a guard aimed at the default tree would read the status of a session that is not the one being submitted. Omitted or null resolves against Le Veilleur's pinned default offering, byte-identical to prior behavior; the response shape is unchanged and does not echo the offering. The Le Marteau grading call carries no offering — Le Marteau reaches no storage. TIMEOUT (v3.42.0): the Le Marteau grading hop is the one A2A call Gaston does NOT give the fleet-wide 30s. Its READ budget is 120s by default (GASTON_GRADE_TIMEOUT_SECONDS), because a worst-case 10-problem sheet can legitimately spend ~80s of Le Marteau's SymPy budget plus a Claude feedback call per wrong answer; at 30s four consecutive submissions failed on 2026-08-07 and the student was shown 'Grading service unavailable'. Connect stays at 30s, so a Le Marteau that is genuinely down still fails fast. Callers must size their own client timeout above this one — a caller that gives up at 60s will abandon a grade that was still legitimately in flight, and the student loses work that exists nowhere else. Every other skill on this card keeps the 30s budget on all four phases. Dispatch is by training_type and belongs to Le Marteau's grader registry, which this wrapper does not duplicate: derivatives_worksheet, partial_derivatives_worksheet, algebra_worksheet, graphing_worksheet (since v3.49.0; its answer is a drawing graded per sub-answer against the stored key) and setup_worksheet (since v3.84.0; forwarded verbatim like the rest, with no branch here). THE GLOBAL COUNTERS ARE FED IN PROBLEM UNITS (v3.49.0). Le Veilleur's log_result moves total_problems_attempted / total_problems_correct by the envelope's total / score and derives overall_accuracy, overall_status and the scoped training_scopes twins from them. The graphing grader returns one row per SUB-ANSWER — thirty for a ten-problem worksheet — so an unconverted graphing session weighed roughly three times its derivatives sibling. When a grader publishes problems_total and problems_correct beside score and total (only the graphing grader does, and it publishes them for exactly this purpose), that pair becomes the envelope's score / total and accuracy is recomputed from it. A grader that publishes neither is already counting problems and its envelope is unchanged — no training_type branch here. A problem counts correct only when every one of its sub-answers is; there is no partial credit, so a 24-of-30 session logs as 4 of 10. problems[] is untouched and still carries all thirty graded rows with their own rule tags, so the topic breakdown is intact and the sub-answer figure stays recomputable. The response returned to the caller is still Le Marteau's result verbatim and carries both pairs.","tags":["grading","conductor","training-hub","typed","mathlive","offering-aware","pending-only","replay-guard","graphing","problem-unit-counters"],"examples":["{\"skill\": \"grade_typed_submission\", \"parameters\": {\"student_id\": \"abc123\", \"session_id\": \"WS-26Sp-123456-1234567890\", \"training_type\": \"derivatives_worksheet\", \"answers\": [{\"problem_number\": 1, \"student_answer_latex\": \"6x+2\"}, {\"problem_number\": 2, \"student_answer_latex\": \"\\\\frac{2x}{x^2+1}\"}, {\"problem_number\": 3, \"student_answer_latex\": \"e^x+xe^x\"}, {\"problem_number\": 4, \"student_answer_latex\": \"3x^2\"}, {\"problem_number\": 5, \"student_answer_latex\": \"\\\\cos x\"}]}}","{\"skill\": \"grade_typed_submission\", \"parameters\": {\"student_id\": \"abc123\", \"session_id\": \"WS-26Sp-123456-1234567890\", \"training_type\": \"derivatives_worksheet\", \"modality\": \"typed_mathlive\", \"answers\": [{\"problem_number\": 1, \"student_answer_latex\": \"6x+2\"}, {\"problem_number\": 2, \"student_answer_latex\": \"\\\\frac{2x}{x^2+1}\"}, {\"problem_number\": 3, \"student_answer_latex\": \"e^x+xe^x\"}, {\"problem_number\": 4, \"student_answer_latex\": \"3x^2\"}, {\"problem_number\": 5, \"student_answer_latex\": \"\\\\cos x\"}]}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"get_progress","name":"Get Progress Report","description":"Retrieve a student's progress report from Le Veilleur. Shows same data to students and TAs for transparency. Accepts an optional offering_id, forwarded to its single Le Veilleur call so the report is read from the named offering. Omitted or null resolves against the pinned default offering.","tags":["progress","report","transparency","student","ta","offering-aware"],"examples":["{\"skill\": \"get_progress\", \"parameters\": {\"student_id\": \"abc123\"}}","{\"skill\": \"get_progress\", \"parameters\": {\"student_id\": \"abc123\", \"offering_id\": \"1\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"download_work","name":"Download Student Work","description":"Package and download all student work (worksheets, submissions, grades) from Le Veilleur. Accepts an optional offering_id, forwarded to its single Le Veilleur call (get_student_data) so the export covers the named offering. Omitted or null resolves against the pinned default offering — note that an export is per-offering, not a union across offerings.","tags":["download","export","data","student","offering-aware"],"examples":["{\"skill\": \"download_work\", \"parameters\": {\"student_id\": \"abc123\"}}","{\"skill\": \"download_work\", \"parameters\": {\"student_id\": \"abc123\", \"offering_id\": \"1\"}}"],"inputModes":["application/json"],"outputModes":["application/json","application/zip"]},{"id":"request_budget_quiz","name":"Request Budget Quiz","description":"Generate a budget-constraint quiz as a PENDING worksheet-lifecycle session, and that is this skill's ONLY flow (v3.65.0): the spring pass-through — one Le Marteau hop, an ephemeral BQ- id, no pending record, no offering anywhere — is REMOVED, not deprecated, and no lifecycle parameter on this skill reaches back to it. Step 0 is request_worksheet's Step 0, structurally verbatim: Le Veilleur check_pending BEFORE any generation, so a refusal costs no SymPy, and a stale session auto-expires on the way. THE ONE PENDING SLOT IS FLEET-WIDE AND BUDGET IS IN IT — an open derivatives, partial-derivatives, algebra or graphing worksheet refuses a budget quiz exactly as a budget quiz refuses them — and the refusal payload is the worksheet path's pending_session_exists shape KEY FOR KEY (success:false, error_code, session_id, expires_at, time_remaining_hours, message), because the Portal interstitial that renders it is one component and must not learn a second dialect; its fallback message is deliberately NOT budget-flavored, since the session holding the slot is usually not a budget one. Calls Le Marteau's generate_budget_quiz with lifecycle=\"pending\", which mints T-{term}-Economics-Budget-{seed}-{timestamp} — an id Le Veilleur v5.129.0 admits with no regex change — then writes the pending record through log_session, the SAME writer the worksheet path uses, carrying the seed, created_at, problem_count, training_type \"budget_quiz\" and Le Marteau's flat {number, rules} answer_key passed through rather than rebuilt (the budget key holds no bank-identity fields to allowlist away, and it is the topic evidence a log_completed_session record could otherwise not carry). NO expires_at is sent: Le Veilleur's own 48-hour constant resolves the deadline — one source of truth rather than a second constant that could drift — and the resolved value comes back on the response. student_id is REQUIRED (a pending record belongs to a student folder; there is no anonymous one). Optional offering_id is threaded to check_pending, to generate_budget_quiz (which derives the {term} segment from it) and to log_session; the guard and the write must resolve the same tree. Returns the problems WITH their solutions, because budget grades in the browser. A failed pending write is NON-FATAL, exactly as on the worksheet path: the quiz comes back without a deadline, and log_completed_session's no-pending-record branch still completes it.","tags":["quiz","budget","practice","interactive","student","worksheet-lifecycle","pending-session","orchestrator"],"examples":["{\"skill\": \"request_budget_quiz\", \"parameters\": {\"student_id\": \"abc123\"}}","{\"skill\": \"request_budget_quiz\", \"parameters\": {\"student_id\": \"abc123\", \"offering_id\": \"Econ3010-26F\", \"domain\": \"general\", \"count\": 5}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"request_isoquant_quiz","name":"Request Isoquant Quiz","description":"Generate isoquant estimation quiz problems for interactive Quiz Mode. Calls Le Marteau's generate_isoquant_quiz skill and returns problem data for the React component.","tags":["quiz","isoquant","practice","interactive","student"],"examples":["{\"skill\": \"request_isoquant_quiz\", \"parameters\": {}}","{\"skill\": \"request_isoquant_quiz\", \"parameters\": {\"difficulty\": 2, \"count\": 3}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"request_graphing_quiz","name":"Request Graphing Session","description":"Generate a ten-problem Graphing session (five single lines, then five continuous piecewise functions) for the Graphing room. Calls Le Marteau's generate_graphing_session and returns the session WITHOUT any answer key: each problem is rebuilt from an allowlist of number / kind / problem_text / axes / grid, so the intercept, the slopes, the kink x-values and the solution segments never reach a browser — an allowlist rather than a deletion, because a denylist would leak the first key-bearing field added to the generator. template_id and level are dropped too (a template id names the shape of the piecewise function). Accepts an optional offering_id and FORWARDS it to Le Marteau — omitted entirely when the caller sends none — because Le Marteau resolves the {term} segment of the GW-{term}-{seed}-{timestamp} session id from it. The session id is returned exactly as minted; Gaston mints none and rewrites none. Synchronous, like request_budget_quiz: one JSON-RPC call, answered in the same response, no queue. Reaches no storage.","tags":["quiz","graphing","practice","interactive","student","answer-key-withheld","offering-aware"],"examples":["{\"skill\": \"request_graphing_quiz\", \"parameters\": {}}","{\"skill\": \"request_graphing_quiz\", \"parameters\": {\"student_id\": \"abc123\", \"offering_id\": \"Econ3010-26F\", \"seed\": 847291}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"get_class_overview","name":"Get Class Overview","description":"Retrieve class-wide analytics for the TA Dashboard. Shows all students sorted by status (red first). Does NOT accept an offering_id and reads the pinned default offering only — Le Veilleur's get_class_overview takes course / semester / sort_by / filter_status and no offering parameter in v5.59.0, so there is nothing for Gaston to forward. Blocked on Le Veilleur; this skill becomes offering-aware the moment Le Veilleur's does.","tags":["dashboard","class","analytics","offering-blind"],"examples":["{\"skill\": \"get_class_overview\", \"parameters\": {}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"request_cost_minimization_quiz","name":"Request Cost Minimization Quiz","description":"Generate cost minimization quiz problems for interactive Quiz Mode. Calls Le Marteau's generate_cost_minimization_quiz skill and returns problem data for the React component.","tags":["quiz","cost-minimization","practice","interactive","student"],"examples":["{\"skill\": \"request_cost_minimization_quiz\", \"parameters\": {}}","{\"skill\": \"request_cost_minimization_quiz\", \"parameters\": {\"mode\": \"mixed\", \"count\": 10}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"submit_cost_minimization_result","name":"Submit Cost Minimization Result","description":"Submit completed Cost Minimization quiz results. Generates personalized LLM analysis and a branded PDF report. Returns report + PDF to Portal. Self-graded client-side; does not log to Le Veilleur. Makes one read from Le Veilleur — get_student_data, only when the caller omits student_name — and accepts an optional offering_id which is forwarded to it, so the name on the PDF is read from the right offering's profile. Omitted or null resolves against the pinned default offering.","tags":["quiz","cost-minimization","result","report","pdf","student","offering-aware"],"examples":["{\"skill\": \"submit_cost_minimization_result\", \"parameters\": {\"computing_id\": \"abc123\", \"session_id\": \"TQ-26Sp-12345-1710432000\", \"score\": 8, \"total\": 10, \"accuracy\": 80, \"offering_id\": \"1\"}}"],"inputModes":["application/json"],"outputModes":["application/json","application/pdf"]},{"id":"submit_budget_session","name":"Submit Budget Session","description":"Orchestrate end-of-session for a completed Budget Quiz: generate the Gaston analysis report, call Le Veilleur render_and_log_budget_session (which renders both PDFs, persists them to Box and logs the session via log_completed_session), return report + PDFs to the Portal. IT TAKES AN OFFERING NOW (v3.65.0). This was Gaston's LAST offering-blind write, and the block was Le Veilleur's rather than Gaston's: render_and_log_budget_session took no offering parameter at all until Le Veilleur v5.129.0, so every budget PDF and every profile counter its delegate moved landed in the pinned default tree whatever offering the student was working under. It now forwards one to every storage call and across the log_completed_session hop, so the optional offering_id goes on the request; absent/None still resolves the pinned default, byte-identical to before. THE CLIENT'S RESULT ROWS ARE FORWARDED UNTOUCHED — problem_number, correct, rules[], the per-part rows, student_segments, the typed slopes and the legacy number/is_correct pair alike — because Le Veilleur stores them as sent and rules[] is the ONLY route a record log_completed_session composes has to the Portal's session_topic_breakdown; a projection introduced here would silence the budget quiz's topic evidence one hop before the write. The reply's additive merged_into_pending is read TOLERANTLY (absent reads false) and surfaced to the Portal, never branched on: since request_budget_quiz opens a pending record at generation, a completion now normally lands ON one. THE REPORT PROMPT READS PROBLEMS, NOT ROWS (v3.66.0). The client's list holds two kinds of row — one SKETCH row per problem, and one SLOPE PART row per graded typed slope, whose problem_number is the STRING \"N-slope-i\" with part / sub_answer / parent_problem beside it — and _generate_report's slim five-name projection used to run over every one of them, so a five-problem session reached the LLM as TWELVE problems: seven a hyphenated identifier where a number belongs, with template_id, level and per_problem_feedback all null, against a prompt that states the quiz is five problems and prescribes a \"You scored X/5\" format. The projection now SKIPS a row carrying part or sub_answer — Le Veilleur's word for a row that arrives as part of a problem and the Portal's, the name its own _detail_problems branches on; the surface sends both and either alone is enough — and never by parsing problem_number for a hyphen, because an id is not a type. SKIPPED FOR THAT READER ONLY: the list forwarded to Le Veilleur is unchanged, every row by reference, so the slope evidence and its rules[\"slope\"] still reach the record. The two names the projection reads (number, is_correct) are still the legacy pair the surface sends beside the new spellings. Future work, named and not done: the report could READ the slope evidence rather than skip it — a prompt change and a projection change taken together.","tags":["budget-constraint","session","submit","training-hub","orchestrator","offering-aware","worksheet-lifecycle"],"examples":["{\"skill\": \"submit_budget_session\", \"parameters\": {\"computing_id\": \"abc123\", \"offering_id\": \"Econ3010-26F\", \"session_id\": \"T-26F-Economics-Budget-847291-1786071569\", \"problems\": [], \"score\": 4, \"total\": 5}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"request_ic_quiz","name":"Request IC Quiz","description":"Generate a Sketch Indifference Curves session — the Preferences room's one training, training_type \"ic_quiz\" — as a PENDING worksheet-lifecycle session (v3.67.0). request_budget_quiz's SIBLING, step for step, with three names changed: Le Marteau's generate_ic_quiz in place of generate_budget_quiz, the training_type literal, and the log lines. Step 0 is Le Veilleur check_pending BEFORE any generation; the one pending slot is fleet-wide, and the refusal is the worksheet path's pending_session_exists payload KEY FOR KEY (success:false, error_code, session_id, expires_at, time_remaining_hours, message), its fallback message the budget mint's own constant, imported rather than copied. Calls generate_ic_quiz with budget's request shape — domain, level, count, lifecycle=\"pending\", student_id, and offering_id OMITTED when absent — which mints T-{term}-Economics-Preferences-{seed}-{timestamp}, then writes the pending record through log_session carrying the seed, created_at, problem_count, training_type \"ic_quiz\" and Le Marteau's answer_key passed through (each entry the whole problem, utility_text / k_level / direction included). NO expires_at is sent: Le Veilleur's own 48-hour constant resolves the deadline and the resolved value comes back on the response. student_id is REQUIRED. Optional offering_id is threaded to check_pending, generate_ic_quiz and log_session. The Portal also sends integer_endpoints; it is NOT forwarded, because request_budget_quiz does not forward it and Le Marteau does not read it. Returns the problems WITH their solutions, because the surface grades the drawing, the segment count, the slopes and the direction in the browser. A failed pending write is NON-FATAL, as on budget.","tags":["quiz","indifference-curves","preferences","practice","interactive","student","worksheet-lifecycle","pending-session","orchestrator"],"examples":["{\"skill\": \"request_ic_quiz\", \"parameters\": {\"student_id\": \"abc123\"}}","{\"skill\": \"request_ic_quiz\", \"parameters\": {\"student_id\": \"abc123\", \"offering_id\": \"Econ3010-26F\", \"domain\": \"general\", \"count\": 5}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"submit_ic_session","name":"Submit IC Session","description":"Orchestrate end-of-session for a completed Sketch Indifference Curves session (v3.67.0): generate the Gaston analysis report, call Le Veilleur render_and_log_ic_session (to render both PDFs, persist them to Box and log the session via log_completed_session), return report + PDFs to the Portal. submit_budget_session's SIBLING, with one call renamed: render_and_log_ic_session is created by Le Veilleur's Preferences brief and is sent render_and_log_budget_session's parameters KEY FOR KEY (student_id, student_name, session_id, problems, score, total, accuracy, report_text, duration_seconds, offering_id). Until Le Veilleur ships it, the call answers with an A2A error and this skill returns render_failed with the report attached. Optional offering_id is forwarded; absent/None resolves the pinned default. THE CLIENT'S RESULT ROWS ARE FORWARDED UNTOUCHED, by reference — four kinds: one SKETCH row per problem (the generated problem spread whole, so utility_text, k_level and direction ride on it), and the COUNT (\"N-count\"), SLOPE (\"N-slope-i\") and DIRECTION (\"N-direction\") part rows — because rules[] on a row is the only route a composed record has to the Portal's Scores by Topic. THE REPORT PROMPT READS PROBLEMS, NOT ROWS, through submit_budget_session's own _is_part_row predicate, imported rather than copied: a row carrying part or sub_answer is skipped, which catches every count, slope and direction row with no new name — and a \"direction\" marker would have skipped every sketch row, since each carries direction \"NE\". The reply's additive merged_into_pending is read tolerantly (absent reads false) and surfaced, never branched on.","tags":["indifference-curves","preferences","session","submit","training-hub","orchestrator","offering-aware","worksheet-lifecycle"],"examples":["{\"skill\": \"submit_ic_session\", \"parameters\": {\"computing_id\": \"abc123\", \"offering_id\": \"Econ3010-26F\", \"session_id\": \"T-26F-Economics-Preferences-847291-1786071569\", \"problems\": [], \"score\": 4, \"total\": 5}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"submit_graphing_session","name":"Submit Graphing Session","description":"Orchestrate end-of-session for a completed ten-problem Graphing session: call Le Veilleur's render_and_log_graphing_session (which grades per SUB-ANSWER — three independently verdicted, independently rule-tagged sub-answers per problem — renders both PDFs, persists them to Box and logs the session via log_completed_session), then write the Gaston report FROM the graded rows and return report + graded rows + PDFs to the Portal. GASTON GRADES NOTHING AND TAGS NOTHING: the rule tags are Le Marteau's, authored at generation; the verdicts are Le Veilleur's; this skill carries the problems list verbatim. Accepts an optional offering_id and FORWARDS it — Le Veilleur's graphing skill threads it to every storage call and across the log hop, which is what makes this path offering-aware from its first release, unlike submit_budget_session. The session id passes through unchanged. THE REPORT IS WRITTEN AFTER GRADING, not before, because Gaston does not know the score until Le Veilleur answers — so the report reaches the Portal and the student but not the archived results PDF, whose report_text must be an input to the same atomic render+log call. The problems list must carry each problem's authored answer_key alongside the student's work; request_graphing_quiz withholds that key from the browser, so a caller must supply it from a server-side source (that source is not built yet — see the skill's module docstring).","tags":["graphing","session","submit","training-hub","orchestrator","offering-aware","server-graded"],"examples":["{\"skill\": \"submit_graphing_session\", \"parameters\": {\"computing_id\": \"abc123\", \"session_id\": \"GW-26F-847291-1786071569\", \"offering_id\": \"Econ3010-26F\", \"problems\": []}}"],"inputModes":["application/json"],"outputModes":["application/json","application/pdf"]},{"id":"book_session","name":"Book Session","description":"Book a 1:1 tutoring session — validates the requested time, refuses a booking whose student_computing_id is the offering's director or one of its TAs (individual sessions are booked by students), enforces the round's booking_opens date and the 6-hour booking lead time, keeps the one-active-booking-per-round invariant by REPLACING any booking the student already holds in that round rather than refusing (see A REBOOK REPLACES below), validates availability, creates the booking, generates an ICS calendar invite, sends the STUDENT's confirmation email (the TA is not mailed — see below). THE ROUND POLICY (v3.48.0): a round is a booking window authored per offering under course_config[\"booking\"][\"rounds\"] — {id, label, opens, closes, booking_opens, duration_minutes, required}, inclusive on both ends, ids stable and never reused. The rule is ONE BOOKING PER STUDENT PER ROUND, checked after the time validation and BEFORE the availability call, so a refusal writes nothing and mails nobody. Exam weeks are expressed by absence: a date covered by no round is refused, with no blackout logic anywhere. New optional parameter offering_id, forwarded to get_course_config ONLY — rounds are per-offering, while the booking storage calls (get_availability, get_bookings, save_booking, get_student_bookings) accept no offering and are deliberately sent none. New refusal slugs, all in the existing {success: false, error, message} shape so the Portal's error map extends rather than changes: \"policy_misconfigured\" (the booking.rounds block is present and untrustworthy — rounds not a list, a round missing id/opens/closes, a non-date bound, opens after closes, duplicate ids, a non-positive duration_minutes, or two windows covering the requested date; fails closed so a typo cannot silently disable the rule, with the round and field named in the log), \"no_open_round\" (no round's window contains the requested date; the message names the next round's label and open date when there is one), \"policy_check_unavailable\" (course_config or the existing-bookings read did not complete — including Le Veilleur's complete: false partial read, which means UNKNOWN and is never read as \"no booking found\", since that is exactly how a student books twice), and \"round_already_booked\" (HISTORICAL — RETURNED BY NO PATH SINCE v3.56.0; callers must not map it. It refused a student who already held a non-cancelled booking in the round, naming the round and that booking's date and time. Since v3.56.0 that case is not a refusal at all — the second booking REPLACES the first, which is the current and only behaviour; see A REBOOK REPLACES below), and \"cancel_closed\" (v3.58.0 — this booking would REPLACE one that starts in less than 24 hours; a rebook withdraws the session it replaces, so the cancellation cutoff applies to it, measured against the OLD session's start. Refused in the round policy, before the availability read and before any write, so the student keeps the session they have and never holds two; skipped for a configured test account, and unreachable by the offering's staff, whom staff_cannot_book refuses first). BOOKING OPENS BEFORE THE SESSIONS DO (v3.53.0): opens/closes are the SESSION window — which dates a session may fall on — and one date used to gate the booking form as well, which cannot hold when round 1's sessions run Aug 25–28 and booking has to open on Saturday the 22nd. The new OPTIONAL per-round field booking_opens (YYYY-MM-DD) is the date from which a booking may be WRITTEN. It is compared against TODAY in America/New_York, inclusive and as a plain ISO string compare — the same convention the session window uses — while opens/closes keep their meaning untouched and are still compared against the REQUESTED SESSION DATE. Two questions against two different dates, which is why the gate could not be folded into the window check. ABSENT MEANS opens, so every config authored before v3.53.0 keeps behaving exactly as it did and stays valid. Booking remains allowed through closes; no upper bound on today was added, since that would be a new policy rather than this split. New refusal slug \"booking_not_open\" (the round exists and the requested date is valid, but today is before that round's booking_opens); its message NAMES THE DATE — \"Booking opens 2026-08-22.\" — because \"not yet\" without a date is a refusal a student cannot act on. It is checked after the date is known valid and BEFORE the existing-bookings read, so a student who is simply early costs no Box round trip. A booking_opens that is not a real date, or that falls after the round closes (a window nobody could ever book), refuses with \"policy_misconfigured\" on the same fail-closed rule the bounds use; a booking_opens LATER than opens is allowed without comment, since a round added mid-term legitimately has session days already behind it. THE TEST-ACCOUNT EXEMPTION (v3.53.0): a student_computing_id listed under course_config.test_accounts skips the booking_opens gate ENTIRELY, so the whole flow can be rehearsed end to end without editing round dates and remembering to put them back. Being listed IS the exemption — there is no new config field and no redirect_to is required (unlike the mail guard, which needs one before it can divert anything). Nothing else is skipped: session-date validity, the one-booking-per-round rule, availability and the save check all still apply, so a test account books a real slot on a real date — it just books it early. THE STORED RECORD GAINS round_id AND offering_id (v3.48.0), omitted entirely when there is nothing to write. save_booking stores the booking dict verbatim with no field whitelist, so no Le Veilleur change was needed and these are fields inside an opaque payload rather than offering threading. The check matches on round_id and NOT merely on the date window, because bookings live in one global date-major store shared across offerings: a student enrolled in two courses whose rounds overlap would otherwise be refused their second course's session by their first course's booking. A record with no round_id predates this feature and matches no round. THE ROUND DEFINES THE SESSION LENGTH: duration_minutes is taken from the round and overrides any caller-supplied value (the 10-minute diagnostic versus a 20-minute individual session), and the resulting end time is re-validated, so a round duration that would push a late slot past midnight is refused with \"invalid_time\" before the save. THE SAVE IS CHECKED (v3.45.0): Le Veilleur reports a Box write failure as {success: false, error: \"save_failed\"} inside a successful JSON-RPC result, so a transport error never fires; that result is now read, and a booking whose persistence was not confirmed produces NO ICS and NO email to either party, returning {success: false, error: \"specialist_unavailable\", message: \"Could not save booking: <detail>\"} instead. Before v3.45.0 such a booking reported success and mailed both parties an invite for a session that existed nowhere. TIME VALIDATION (v3.45.0): the time is parsed rather than string-sliced and normalized to HH:MM, so an unpadded \"9:00\" books the 09:00 slot instead of failing; a time that cannot be parsed, or a session that would run to or past midnight (which has no valid same-day DTEND), is refused with error \"invalid_time\" BEFORE any Le Veilleur call, rather than saved and then given a malformed invite. MAIL PROTECTIONS (v3.45.0): confirmation mail is subject to the master email kill-switch, a new email.booking_enabled channel flag (absent means enabled), and the test-account redirect — all read from the same course_config keys send_email_core reads. Held mail does not fail the booking: the record is saved and emails_sent reports false. THE TA IS NOT EMAILED (v3.53.0): booking used to mail the TA a confirmation and an ICS of their own. Across five TAs and roughly 250 bookings that is noise, and it duplicates the two surfaces a TA actually watches — Le Coin du TA and their calendar feed — so the TA leg is removed entirely: no send, no ICS attachment, no TA template. There is NO config switch; removal is the decision, not a default. The STUDENT leg and every protection around it are untouched, and the student's ICS still names the TA as an ATTENDEE, because being a party to the session is a different fact from being a recipient of the mail. RESPONSE SHAPE: emails_sent now carries only {student: bool} — the \"ta\" key is gone rather than pinned false, since a permanently-false key would read as a TA email that FAILED rather than one that was never meant to go. Nothing else on the success path changed. WHERE THE SESSION IS (v3.54.0): a round may carry two new OPTIONAL fields — location, the SHORT form fit for a table row and a calendar's venue strip (\"Monroe Hall, basement level\"), and location_directions, a wayfinding sentence. The confirmation gains a Location ROW carrying the short form and the directions sentence ONCE, in the body, as prose — a wayfinding sentence in a table cell beside \"Time\" reads as noise. The ICS gains LOCATION, short form only, because a client renders that property in a strip beside the time. ABSENT MEANS ABSENT, NEVER EMPTY: no location, no row and NO LOCATION: property at all, since an empty property is a venue field some clients render blank rather than omit; the two fields are independent, so a round with directions and no short form still gets its paragraph. Both fields DEGRADE where every other round field refuses — a mistyped date takes the offering's booking down on purpose, but a location decides what a sentence says rather than who may book, so a non-string or blank value logs a warning and resolves to null, the precedent label already set. A loaded round always carries BOTH KEYS, null when unset, so no consumer invents a default. RFC 5545 §3.3.11 ESCAPING (v3.54.0): inside a TEXT value the comma is a VALUE SEPARATOR, so LOCATION and SUMMARY are both escaped (backslash, semicolon, comma, newline — backslash first, so an escape the escaper introduces is never escaped twice). SUMMARY carries two human names and \"Smith, Jr.\" is a name; unescaped it split that property into a two-element list. STAFF CANNOT BOOK (v3.55.0): individual sessions are for students, and nothing refused the one identity that could quietly take a slot from one — a director or a TA of the offering booking a session for themselves. New refusal slug \"staff_cannot_book\", message \"Individual sessions are booked by students.\", raised when student_computing_id matches course_config.staff.instructor.computing_id, any entry in course_config.staff.tas, or - SINCE v3.80.0 - any entry in course_config.staff.graders or course_config.staff.tutors. THE TWO NEW BUCKETS (v3.80.0): Le Veilleur v5.144.0 added staff.graders and staff.tutors and its get_roster emits them as roster rows with role \"grader\" and \"tutor\"; both of this skill's gates missed them - the roster exclusion did not name the roles and the staff membership read only instructor and tas - so a grader or a tutor passed every check and could take a slot a student then could not have. Marc's ruling (2026-09-20): graders and tutors have NO individual sessions, refused as bookers and never hosts; a tutor is a walk-in helper and a grader grades. The SLUG, the MESSAGE and the POSITION are unchanged - the message has never named a role, so it was already true of them - and it covers the graded Individual Session and kind \"ta_time\" identically, because kind is not consulted until well below this check. course_config.staff.eco stays deliberately OUTSIDE this predicate: an ECO is not enrolled, so not_on_roster is the true sentence for her. Comparison is case-insensitive and trimmed. It is the FIRST of the round rules and runs before the booking_opens gate: it costs a set membership against the config already read for the policy, so no rounds are parsed and no Box read is spent, and the refusal is honest about its reason — a TA is refused for being a TA, never for the state of their offering's windows. NO COMPUTING ID IS WRITTEN DOWN IN CODE: the membership is read from this offering's own staff block, the same block the Portal's pickers, Le Bureau and Le Coin du TA read and the one Le Veilleur builds its role map from, so a TA removed from the config stops being refused on the same edit that revokes their calendar feed. staff.eco is deliberately NOT included — an ECO is not party to an individual session in either direction, and an offering that wants them refused adds them to the block. A TEST ACCOUNT IS NOT AN EXEMPTION FROM IT: test_ta is listed under BOTH staff.tas and test_accounts and is refused like any other TA, because the test-account exemption says a booking may be made EARLY and has never said anything about who may make one; test_student is in no staff block and books exactly as a student does, which is what makes the rehearsal worth running. CANCELLATION IS NOT GATED, deliberately: nothing staff-owned should exist after this, but a booking made before it — or by a path nobody has thought of — must still be removable, and a refusal on a cleanup is the one refusal with no good outcome. cancel_session is untouched. ONLY THE ROSTER MAY BOOK (v3.56.0): \"not staff\" was the wrong SHAPE for the question and v3.55.0's gate only looked like an answer — it refused the two identities somebody had thought of (the director, the TAs) and admitted every identity nobody had, including staff.eco, who is outside staff_computing_ids by design and could still take a slot. The rule is now positive: student_computing_id must appear on THIS offering's roster as a student — Le Veilleur's get_roster, which is the CSV named by course_config.roster_filename with the offering's staff merged over it and its test accounts appended — or be listed under course_config.test_accounts. New refusal slug \"not_on_roster\", message \"Individual sessions are for students enrolled in this course.\", plain and naming no identifier. THE EXCLUSION IS BY ROLE, NOT BY MEMBERSHIP: get_roster merges staff INTO the roster, so membership alone would admit exactly the identities this gate is for; the excluded roles are instructor, director, ta, eco and - since v3.80.0 - grader and tutor, which is every role get_roster emits, and anything else — student, an unrecognised word, or the empty string get_roster returns for a blank role cell — counts as a student, because a roster whose role column was never filled in must not lock a cohort out of booking. THE ORDER IS STAFF, THEN ROSTER, THEN THE WINDOW: the staff refusal still runs first and still spends no read, because \"you run this offering\" is a different and more useful sentence than \"you are not enrolled\"; staff.eco is caught by the ROSTER gate, which is the honest one for her. FAIL CLOSED WITH ITS OWN SLUG: an unreadable roster (transport error, roster_filename not in the offering root, a success carrying no list) refuses with \"roster_unavailable\" rather than admitting an unchecked booking for the length of a Box outage. THE TEST-ACCOUNT PATH IS ASKED FIRST AND SPENDS NO ROSTER READ AT ALL: test_student is admitted because the config lists it, not because the CSV happens to carry it, which also makes the rehearsal independent of a roster outage; test_ta is still refused one gate earlier, by staff_cannot_book. NOTHING IS CACHED ON GASTON'S SIDE — Le Veilleur caches the roster CSV bytes (get_roster v2.3.0, TTL 300s) and `config refresh` clears that cache, while a second cache here would have no revocation path and would keep serving a roster the director had just replaced. get_roster is called with offering_id when the caller sent one and with no key at all otherwise. A REBOOK REPLACES (v3.56.0): \"round_already_booked\" is GONE as a refusal and callers should stop mapping it. The invariant is ONE ACTIVE BOOKING PER ROUND, not \"you may act once\" — under the refusal a student who wanted a different hour had to cancel and then book, in two acts, with a window between them in which they held nothing. A second booking in a round the student already holds a session in now CANCELS that session (the full v3.54.0 withdrawal: METHOD:CANCEL on the OLD booking's own UID, SEQUENCE:1, and the student's cancellation email) and proceeds. Scoped per round: a booking in another round, or a record carrying no round_id, is never touched. The success payload names both halves — replaced: {booking_id, old_date, old_time, old_ta, old_ta_name, cancelled, emails_sent} — present ONLY on a booking that superseded one, never as a null key. THE ORDER PROTECTS THE STUDENT (v3.56.0): the new booking is saved AND VERIFIED first, and only then is the old one cancelled, so a student mid-swap holds at least one valid booking at every instant. A collision or a failed write on the new booking leaves the original standing, untouched. If the OLD booking's withdrawal fails after a verified new save, the result says so rather than tidying it away: both_held: true, replaced.cancelled: false, both sessions named, and a message the caller can print — the student is holding two, and a surface showing one of them would be showing a schedule that is not theirs. The withdrawal is DELEGATED to cancel_session (one authority on RFC 5546, the save check, the offering resolution and the mail guard) and carries NO actor_computing_id, because the student did this by rebooking and \"cancelled by the course\" would be false. VERIFY THE CHAIR, NOT JUST THE WRITE (v3.56.0): save_booking confirming its own write says the FILE was uploaded, never that this booking is the only one in its chair. Bookings live one JSON document per DATE — a flat list shared by every TA and every offering — and save_booking is an unlocked read-modify-write (download, append-or-replace-by-id, upload the whole document with no If-Match and no ETag); nothing constrains (ta_computing_id, date, time) to be unique. Two students booking one chair at the same moment therefore either lose one booking entirely (the later upload overwrites the earlier, and its student holds an invite for a record that is gone) or leave the chair with two holders. The availability read narrows that window and cannot close it. So the day is re-read after the write and the chair is counted. THE DETERMINISTIC RULE: earliest booked_at keeps the chair; if two records are indistinguishable — equal stamps, a missing one, an unparseable one — the RE-READER YIELDS. Both racers run the same code against the same document and reach the same answer without coordinating, which is the most a last-writer-wins store allows. A booking that yields is WITHDRAWN first (a cancelled record, cancelled_by \"system\", yielded_to naming the winner) and only then is its student answered — new refusal slug \"slot_taken\", message \"That slot was just taken — please pick another.\", with NO ICS and NO email, since the confirmation follows a verified save and nothing else. If that withdrawal itself does not land, the result carries yield_removal_failed: true rather than hiding it. The winner touches NOTHING of the other student's record: a winner that deleted somebody else's booking would be ending a session with no notice to anybody. A booking that is not in the re-read of its own day is unconfirmed rather than taken — that is a write that did not land — and returns \"specialist_unavailable\", because telling a student their slot was taken when it was not is a lie about why. A SESSION IS BOOKED AT LEAST 6 HOURS AHEAD (v3.60.0, decided 2026-08-24; twelve hours until v3.62.0, six since, decided 2026-08-25): nothing on the write path compared the REQUESTED session's start to the current moment — the time validation checks the clock string's SHAPE, the round window and booking_opens are lexical date comparisons against the config, and v3.58.0's cutoff measures the OLD booking on a rebook — so a student could take a slot starting in ten minutes. New refusal slug \"too_late_to_book\", message \"Individual sessions must be booked at least 6 hours in advance. Please choose a later time.\" WHY: the TA is told about a booking by nothing except their own calendar feed and Le Coin du TA (no notification was ever added), and they plan their morning on what was on the board the night before; a slot filled at 08:45 for a 09:10 session is a session nobody is told about. MEASURED AGAINST THE REQUESTED SESSION'S START, which is what makes it a different rule from the cancellation cutoff rather than a symmetric one — that cutoff measures a booking that already exists. BOOKING_LEAD_TIME is its own timedelta beside CANCELLATION_CUTOFF and the two are free to differ; the clock helpers are reused unchanged. THE SEAM: inside the round policy, AFTER the booking_opens gate and BEFORE the existing-bookings read, so the round is known valid and the requested date is placed in a window while nothing has yet been read from Box — a too-soon slot costs one refusal, no bookings read, no availability read, no write and no mail. It therefore applies only where booking.rounds is configured; an offering with no rounds block returns from the policy above that line, as it always has. STRICTLY LESS THAN, so a session exactly 6 hours out still books, matching the cancellation cutoff's boundary convention; an already-started or past session is inside the window by the same arithmetic and carries no separate check. A configured test account SKIPS the gate, by the same listing that skips booking_opens. The offering's staff never reach it — staff_cannot_book refuses them first — so no staff carve-out exists here and there is no staff_override door on this skill. AN UNREADABLE REQUESTED START IS REFUSED, the deliberate asymmetry with cancel_session and with the rebook cutoff, both of which let one THROUGH: a RECORD whose start cannot be read must stay cancellable, while a REQUEST whose start cannot be read has no such claim — the student picks another slot and loses one click, and admitting it would put a booking of unknown time past a rule about time. That case returns \"invalid_time\" (the request's own date and time could not be read) with the detail logged id-free at warning. The picker's own visibility of this rule is a separate change in the Portal; get_available_slots and get_available_slots_bulk remain documented passthroughs and were not touched. TA TIME — A SECOND, UNGRADED BOOKING PER ROUND (v3.79.0). A round may now carry ta_time_opens, the Sunday before its second week, and a student who already holds their graded Individual Session in that round may book twenty UNGRADED minutes with a TA from that date. THREE NEW OPTIONAL PARAMETERS, and an ABSENT kind IS TODAY'S BEHAVIOUR EXACTLY — every individual-session email, ICS and result dict is byte-for-byte what it was, and none of the slugs below is reachable without it: kind (\"ta_time\", and any other value is refused invalid_kind before the clock is consulted), focus (\"topic\", the default, or \"round_problem\") and topic (required when focus is \"topic\", student free text, capped at 500 characters and REFUSED — never truncated — past it). THE INVARIANT IS NOW ONE ACTIVE BOOKING PER ROUND PER KIND. The round's matches are split on the stored record's `kind` (absent means graded, so every record written before this release is graded): a graded rebook replaces only the graded record and leaves a TA Time booking standing, and a TA Time rebook replaces only the TA Time record and leaves the graded session standing, each under the same 24-hour cancel_closed swap rule. THE STORED RECORD gains kind: \"ta_time\", focus, and topic (topic only when focus is \"topic\"); all three are OMITTED ENTIRELY on a graded booking, and the result dict gains kind on a TA Time booking and on no other. ELIGIBILITY IS ONE FUNCTION, utils/booking_support.ta_time_standing, which get_booking_rounds reads as well — so the page cannot offer a button this skill would refuse, or hide one it would honor. New refusal slugs, all in the existing {success: false, error, message} shape: \"invalid_kind\" (kind sent and not \"ta_time\"; nothing is read); \"ta_time_not_offered\" (the offering configures no rounds, or the round containing the date names no ta_time_opens); \"ta_time_not_open\" (today is before that date, and the message NAMES it; a configured test account skips this gate, as it skips booking_opens and the lead time); \"ta_time_wrong_week\" (the REQUESTED date is before that date — today and the session date are two questions against two dates, and a test account is NOT exempt from this one); \"ta_time_needs_session\" (no graded Individual Session held in this round — TA Time is an extra, never a substitute); \"ta_time_after_no_show\" (the graded record in this round is no_show; the recourse is the professor); \"ta_time_conflicts_with_session\" (the requested date and time are the student's own graded session's — the availability read cannot catch this, since the two bookings are with different TAs as often as not); \"ta_time_focus_unavailable\" (focus \"round_problem\" asked for by a student whose graded session did not fall STRICTLY BEFORE ta_time_opens — that option exists for the students who have not seen the current problem); \"ta_time_topic_required\" (focus \"topic\" with an empty topic once stripped); \"ta_time_topic_too_long\" (past 500 characters). THE FIRST THREE ARE ROUND-LEVEL and are refused beside the 6-hour lead-time gate, BEFORE the bookings read and the availability read, so an early or wrong-week request costs one refusal and no Box round trip; the rest need the read that finds the graded session. THE MAIL AND THE INVITE: subject \"TA Time confirmed — {ta_name} on {date} at {time}\", heading \"Your TA Time is confirmed\", a Type row reading \"TA Time — not graded\" (a literal, NOT service_type, which is untouched and still \"1:1 Tutoring\"), a Focus row carrying the student's topic HTML-ESCAPED or \"This week's practice problem\", and ICS SUMMARY \"TA Time — {student_name} + {ta_name}\"; everything else about the invite, the guard, the single student recipient and the save-verify-collision-withdraw sequence is unchanged.","tags":["booking","scheduling","training-hub","email","ics","orchestration","write-confirmed","round-policy","students-only","roster-gated","collision-verified","rebook-replaces","cutoff-24h","ta-time"],"examples":["{\"skill\": \"book_session\", \"parameters\": {\"student_computing_id\": \"abc2def\", \"ta_computing_id\": \"bds4xy\", \"date\": \"2026-04-14\", \"time\": \"14:00\"}}","{\"skill\": \"book_session\", \"parameters\": {\"student_computing_id\": \"abc2def\", \"ta_computing_id\": \"bds4xy\", \"date\": \"2026-08-26\", \"time\": \"14:00\", \"offering_id\": \"1\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"cancel_session","name":"Cancel Session","description":"Cancel a booked 1:1 session — authorizes the canceller, updates status, sends the STUDENT an ICS cancellation that removes the calendar event (the TA is not mailed — see below). AUTHORIZATION (v3.45.0): cancelled_by was free text checked against nothing, so knowing a booking id — which is not a secret — was enough to cancel someone else's session and mail both parties about it. It must now match the booking's student_computing_id or ta_computing_id, or the call is refused with error \"not_authorized\" before any write and with no mail. The one sanctioned bypass is staff_override (bool, default false), a named path for a director or TA acting on someone's behalf; it is deliberately NOT wired to any role lookup in this agent — the caller owns that check. The refusal message names no identifier, so it cannot be used to confirm who is on a booking. The already_cancelled check runs first, so a stranger learns nothing about a booking's state from the error code. THE CANCELLING WRITE IS CHECKED (v3.45.0): the save_booking result was not even assigned, so a failed write returned {success: true, status: \"cancelled\"} and mailed both parties an ICS withdrawal while the stored record still read confirmed. It is now read on the same rule book_session uses; an unconfirmed write returns {success: false, error: \"specialist_unavailable\"} and sends no cancellation mail. THE CANCELLATION ICS IS RFC 5546-CONFORMANT (v3.45.0): it carried no ORGANIZER, no ATTENDEE and no SEQUENCE, so a client with no way to match the withdrawal to the invite could ignore it and leave the cancelled session on the student's calendar. It now mirrors book_session's ORGANIZER and both ATTENDEE lines and carries SEQUENCE:1, outranking the SEQUENCE:0 the original REQUEST now carries. MAIL PROTECTIONS (v3.45.0): identical to book_session's — kill-switch, email.booking_enabled, and the test-account redirect. Held mail does not fail the cancellation. THE TA IS NOT EMAILED (v3.53.0): the TA's cancellation copy and its ICS withdrawal are removed, for the reason book_session's confirmation leg was — noise duplicating Le Coin du TA and the TA's calendar feed. No config switch. The student leg is untouched, and the ICS still carries both ATTENDEE lines because the withdrawal must name the event's real attendees to match the invite it supersedes; the TA never received an invite by mail, so there is no mailbox calendar entry for it to have to reach. emails_sent now carries only {student: bool}. CANCELLATIONS CLOSE 24 HOURS BEFORE THE SESSION (v3.58.0, decided 2026-08-23): a student may not cancel inside 24 hours of the session's own start. The refusal is a new slug \"cancel_closed\", in the existing {success: false, error, message} shape, and its message names the door that is still open: \"Cancellations close 24 hours before a session. Please message the instructor on Teams instead.\" WHY: the TA is not mailed a cancellation (v3.53.0), so a late one reaches them only when their calendar feed next polls, which can be after they have walked to the room. The gate runs BEFORE the write and beside the authorization rather than inside it — \"may this person cancel this\" and \"may this be cancelled now\" are two questions with two slugs — so a refusal leaves the record confirmed, builds no ICS and mails nobody. FOUR EXEMPTIONS, each explicit: the offering's DIRECTOR (keyed on the staff.instructor check, never on the mere presence of actor_computing_id, which also admits the student themselves and would otherwise be a way around the rule); the booking's OWN TA, reached from either door, because their own hour is theirs and they are the party the rule protects; a staff_override caller; and a computing id listed under course_config.test_accounts, mirroring book_session's booking_opens carve-out so the whole live flow can still be rehearsed in one sitting. The session start is built from the record's own date and time; a record whose start cannot be read passes the gate with its id in the log, because a legacy row's unknowable start must not trap a student inside a booking. The comparison is made on the course's clock (America/New_York), not UTC. book_session enforces the same cutoff on the REBOOK path against the OLD booking's start, before any write, so a late swap is one clean refusal rather than a new session plus a stranded old one. NO BOOKING WINDOW GATES A CANCELLATION, which is a different rule and still holds: book_session refuses a write before a round's booking_opens and there is no mirror of THAT here, so a test account that booked early can cancel that booking immediately. The cutoff is bounded by the session, not by a round's window. THE MAIL GUARD READS THIS BOOKING'S OFFERING (v3.54.0) — THE FIX FOR THE CANCELLATION EMAIL THAT WENT NOWHERE. On the evening of 2026-08-21 a rehearsal booking produced a confirmation and its cancellation produced nothing at all, with no error on any surface. The student leg was never removed — v3.53.0 took the TA's copy and left this one. What was wrong was which document the mail guard read, and it had been wrong since v3.48.0: that release made book_session read course_config WITH the booking's offering_id (rounds are per-offering) while cancel_session kept calling the guard's own loader, which sends NO offering_id, and Le Veilleur resolves that to the env-pinned default root. On a deployment whose active offering is not the pinned one the two skills judged one booking against two different documents, and the guard's failure mode is silence by design: an unreadable config, or one with no email block (email.enabled defaults to FALSE), means hold the mail, logged at INFO and nowhere else. THE FIX: the offering comes from the STORED RECORD's offering_id, stamped at save time by book_session. A new OPTIONAL offering_id PARAMETER is a fallback only — for a legacy record written before that stamp existed — and never overrides the record, because the stamp is a fact and the parameter is a guess; with neither, the key is omitted entirely, which is the pre-v3.48.0 read. It is forwarded to get_course_config ONLY: get_bookings and save_booking still accept no offering and are sent none. ONE READ SERVES TWO CONSUMERS — the mail guard and the round's location. THE SILENCE IS OVER: a held send now logs at WARNING naming the reason and the offering, and the reason rides back in the result as mail_skipped, present ONLY when a send was held so it reads as an exception worth noticing rather than a field worth checking — emails_sent {student: false} says a send FAILED, mail_skipped says one was never ATTEMPTED, and the two were previously indistinguishable. The subject now names the TA as well as the date and time, matching the confirmation's. WHERE THE SESSION WAS (v3.54.0): the same config read yields the round, so the cancellation names the round's location SHORT FORM — a student cancelling one of several sessions should not have to work out which one — and the withdrawal ICS carries it as LOCATION, escaped per RFC 5545. location_directions is deliberately NOT here: nobody needs directions to a session that is off. The round is found by the record's round_id first and by the booking's DATE second (a legacy record still falls in exactly one window); a round_id naming a round that no longer exists in the config resolves to NO PLACE rather than to whichever window spans the date. None of this can fail a cancellation — the write has already landed, so a malformed booking.rounds block costs the Location line and nothing else; refusing such a block is book_session's job, BEFORE anything is stored. THE ON-BEHALF PATH IS ROLE-CHECKED (v3.56.0): the director cancels a student's session from Le Bureau, and staff_override — a boolean the caller asserts about ITSELF — is not the shape that should carry it. It says \"trust me\" rather than saying who is asking, and nothing at this end could audit it. New OPTIONAL parameter actor_computing_id says WHO, and this skill checks it against the offering's own course_config.staff.instructor.computing_id (trimmed, case-insensitive, no id written down in code). The director may cancel ANY student's booking; any other actor must be that booking's own student, and a mismatch is refused \"not_your_booking\" before any write and with no mail. The TA is deliberately NOT on this door — a TA cancelling a session they are on arrives as cancelled_by on the student-initiated path, where the parties check admits them unchanged — because this path answers \"may this person act on somebody ELSE's booking\" and for a TA that is a decision nobody has made. ABSENT IS BYTE-IDENTICAL: a call with no actor_computing_id (or a blank one) takes exactly the v3.55.0 path — the same parties check, the same staff_override escape, the same Le Veilleur calls in the same order with the same payloads, the same result keys. staff_override is not deprecated and the two never interact. WHERE THE CONFIG READ MOVES, AND ONLY THEN: authorisation must happen BEFORE the write, and the director's identity lives in course_config, which this skill did not read until AFTER the cancellation was saved. On the actor path that ONE read moves up, to just after the booking is found (its offering is known by then, off the stored record), and step 3b reuses it rather than making a second — a second read would be a second chance for one cancellation to disagree with itself about who the director is. On the no-actor path nothing moves. An actor path whose config cannot be read cannot establish that the actor is the director, so it refuses \"specialist_unavailable\", whose promise that nothing was changed is literally true there. THE STUDENT IS STILL THE ONE WHO IS EMAILED, and that is the point rather than an oversight: a session cancelled out from under a student is exactly the session they most need to be told about. A DIRECTOR-INITIATED cancellation adds ONE sentence to the same mail — \"This session was cancelled by the course.\", directly under the heading and above the table — because \"Cancelled by: <id>\" in a table row is not a student telling themselves what happened. It says WHAT happened and not why; a director who wants to say more has `reason`, which renders in its own row as it always has. Everything else is identical between the two: same subject, same table, same METHOD:CANCEL ICS on the booking's own UID with SEQUENCE:1, same mail guard. The TA is still not mailed, on this path as on every other. A student cancelling their OWN booking through the actor path gets no added sentence, because the course did not do it. CALLED BY book_session ON A REBOOK (v3.56.0): a booking that replaces one the student already holds in the round delegates that withdrawal here, with cancelled_by set to the student and NO actor_computing_id, so it takes the ordinary student-initiated path and carries no \"cancelled by the course\" sentence. book_session saves and VERIFIES the new session before making that call, so a failure here leaves the student holding two sessions rather than none, and book_session reports it as both_held. THE SERVICE-TYPE BRANCH (v3.75.0): a stored record whose service_type is \"Office Hours\" is judged against its offering's course_config.office_hours.cancel_cutoff_hours instead of the 24-hour cutoff (the refusal is still cancel_closed, worded \"Office-hours cancellations close {n} hours before the meeting.\"), and an office-hours record whose offering has no readable, valid office_hours block is refused \"policy_check_unavailable\" before the write rather than given a guessed cutoff; the withdrawal ICS carries the office-hours SUMMARY, the record's own location (room or URL) and a VTIMEZONE. Authorization (the student, the host as the record's ta_computing_id, the director, staff_override), the four exemptions, the write shape and the cancellation email are the path above, unchanged. ACCEPTED ASYMMETRY: office hours book up to booking_lead_minutes ahead but cancel only outside cancel_cutoff_hours, so a meeting booked inside the cutoff window cannot then be cancelled by the student. TA TIME (v3.79.0) — WORDING ONLY. A record carrying kind: \"ta_time\" is withdrawn by exactly this sequence: the same 24-hour cutoff, the same authorization, the same write check, the same METHOD:CANCEL on the same UID with SEQUENCE:1, the same mail guard and the same single student recipient. What changes is the noun: subject \"TA Time cancelled — {ta_name} on {date} at {time}\", heading \"TA Time cancelled\", and ICS SUMMARY \"CANCELLED — TA Time\". The individual-session cancellation is byte-for-byte unchanged, as is the office-hours branch.","tags":["booking","cancellation","email","ics","orchestration","authorized","write-confirmed","director-cancel","on-behalf","cutoff-24h","ta-time"],"examples":["{\"skill\": \"cancel_session\", \"parameters\": {\"booking_id\": \"BK-20260410-143022-abc\", \"date\": \"2026-04-14\", \"cancelled_by\": \"abc2def\"}}","{\"skill\": \"cancel_session\", \"parameters\": {\"booking_id\": \"BK-20260410-143022-abc\", \"date\": \"2026-04-14\", \"cancelled_by\": \"dir7kk\", \"staff_override\": true, \"reason\": \"TA out sick\", \"offering_id\": \"Econ3010-26Fa\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"mark_no_show","name":"Mark No Show","description":"Record that a booked student did not attend their 1:1 session — reads the record, authorizes the actor, checks that the session has started, writes status \"no_show\" on the whole stored record, verifies the write, and tells the student (v3.63.0 NEW). A SIBLING OF cancel_session, NOT A VARIANT OF IT: the only status a booking could leave \"confirmed\" for was \"cancelled\", and cancelling a session that HAPPENED is a false record — it says the hour was given back when in fact it was spent. THE STORE NEEDED NO CHANGE: Le Veilleur's save_booking writes the booking dict verbatim with no field whitelist and updates in place by id, and every reader in the fleet filters on status == \"cancelled\" and passes any other value through, so the whole of this feature is one Gaston skill and no Le Veilleur release. THE STORED RECORD gains no_show_marked_by (the actor's computing id), no_show_marked_at (UTC ISO) and no_show_reason (free text, \"\" when absent) alongside the new status; the rest of the record is written back intact, since save_booking keeps no field whitelist and a partial write would be a booking with most of itself missing. NO ICS, deliberately: a no-show removes nothing from anybody's calendar — the session happened, and a METHOD:CANCEL for it would withdraw an event that was correct. AUTHORIZATION IS THIS SESSION'S TA, OR THE COURSE. actor_computing_id is REQUIRED. It passes when it equals the record's own ta_computing_id (trimmed, case-insensitive), because the TA is the only person who knows whether the student was in the room; it passes when it is the offering's director (course_config.staff.instructor.computing_id, the same one authority is_offering_director reads for cancel_session's on-behalf path), because a mark made in error must be correctable by somebody and Le Bureau is where that happens. Every other identity is refused \"not_your_session\" — \"Only this session's TA or the course can mark a no-show.\" THE BOOKING'S OWN STUDENT IS DELIBERATELY NOT ON THIS DOOR: a student cannot record their own absence, and a student who could would be the only party with a motive to record somebody else's. The refusal message names no identifier, so it cannot be used to confirm who is on a booking. THE CONFIG IS READ ONCE AND BEFORE ANYTHING IS WRITTEN, because the director check depends on it and an authorization decided after the write is not one; that one read serves both it and the mail guard. An unreadable config cannot establish that the actor is the director, so it refuses \"specialist_unavailable\" with the sentence that nothing was changed, which is literally true there. WHICH OFFERING is the stored record's own offering_id, stamped by book_session at save time, with an OPTIONAL offering_id parameter as a legacy fallback only and never as an override — the stamp is a fact and the parameter is a guess; with neither, the key is omitted entirely. It is forwarded to get_course_config ONLY: get_bookings and save_booking accept no offering and are sent none. A SESSION CAN BE MARKED ONLY AFTER IT HAS STARTED. The gate sits beside the authorization rather than inside it — \"may this person mark this\" and \"may this be marked yet\" are two questions with two slugs — and it runs BEFORE the write, so a refusal leaves the record confirmed and mails nobody. A session still in the future is refused \"session_not_started\" — \"A session can be marked as a no-show only after it has started.\" — since a mark on an hour nobody has lived through is most likely a mistyped date on the TA's screen; the comparison is made on the COURSE's clock (America/New_York) against the record's own date and time, and a session marked AT its own start is allowed. THERE IS NO 24-HOUR CUTOFF AND NO BOOKING WINDOW OF ANY KIND: cancel_session's cutoff exists to stop a session vanishing from under a TA who has planned their morning around it, and a no-show is recorded by that same TA after the fact. AN UNREADABLE START IS REFUSED with \"session_start_unreadable\", the deliberate asymmetry with cancel_session, which lets one THROUGH: a RECORD whose start is unknowable must stay cancellable or a student is trapped inside a booking forever, while nothing traps anybody here and a no-show written against an hour that cannot be placed on the clock is an assertion this skill cannot support. THE TWO STATUSES THAT ARE NOT \"confirmed\", each answered differently. A CANCELLED record refuses \"already_cancelled\": a session that was called off is not a session the student failed to attend, and the two facts must not overwrite each other in either direction. A record ALREADY AT no_show returns SUCCESS — {success: true, status: \"no_show\", already_marked: true, emails_sent: {student: false}} — writing nothing, mailing nobody and spending no config read, because a TA who double-clicks (or two surfaces that both submit) must not produce two emails to a student about one absence, and must not be told they did something wrong for arriving at the state they asked for. already_marked is present ONLY on that path, never as a permanently-false key. THE WRITE IS CHECKED, NOT ASSUMED, on the same rule book_session and cancel_session use: Le Veilleur reports a Box write failure as {success: false, error: \"save_failed\"} INSIDE a successful JSON-RPC result, so A2AError never fires; an unconfirmed write returns \"specialist_unavailable\" and sends NO mail, because telling a student they were marked absent while the stored record still reads \"confirmed\" is the one outcome worse than saying nothing. THE STUDENT IS TOLD, and that is the point rather than a courtesy: a no-show is a record about a person, held in a store they cannot read, and the first time they hear about it must not be a grade conversation weeks later. Subject: \"Missed session — {ta_name} on {date} at {time}\", matching the cancellation's shape. The body is the cancellation mail's register — the session that was booked (student, TA, date, time, and the round's location SHORT form when the config yields one, resolved by round_id first and by date second exactly as cancel_session resolves it), then TWO sentences and no more (v3.63.1): that they were booked for this session and did not attend, and that they must reach out to the PROFESSOR immediately and not book another session until they have spoken with him. A missed session is a conversation with the professor, not a rebooking, so the mail names NO path back to the booking page - deliberately, and not by omission; the TA made the mark, so they are not the person to appeal it to either. The stored reason is NOT rendered to the student — it is the TA's note to the course. THE NOTICE BRANCHES ON THE STORED RECORD'S service_type (v3.77.0), exactly as cancel_session's does: an \"Office Hours\" record is mailed subject \"Missed office hours — {host_name}\", names the host as \"Host\" rather than \"TA\", carries no \"another session\" sentence and no round language (it asks the student to cancel ahead of time if they cannot make a booked meeting), and takes its location from the record's own location field — the room, or the Zoom URL as a link when online — never from booking.rounds, so a meeting whose date falls inside a round window is not named with that round's room; the individual-session notice above is byte-for-byte unchanged, and the guard, the single recipient and the absence of an ICS are the same on both branches. THE TA IS NOT MAILED, on this path as on every other, and here for an extra reason: the TA is the person who made the mark. Mail is best-effort in cancel_session's exact sense — the mark is written first and neither a failed send nor a held one un-writes it; the guard is the same kill-switch, email.booking_enabled flag and test-account redirect, a held send logs at WARNING and rides back as \"mail_skipped\" (present ONLY when a send was held, so emails_sent {student: false} still means a send FAILED and mail_skipped means one was never ATTEMPTED). SUCCESS SHAPE: {success: true, booking_id, status: \"no_show\", marked_by, emails_sent: {student: bool}} plus mail_skipped only when held. Refusal slugs, all in the existing {success: false, error, message} shape so the Portal's error map extends rather than changes: booking_not_found, already_cancelled, not_your_session, session_not_started, session_start_unreadable, specialist_unavailable. NOTHING IS IMPORTED FROM ANOTHER SKILL MODULE: the agent's loader registers every BaseSkill subclass it finds by inspect.getmembers, so a top-level import of CancelSessionSkill here would register a second cancel_session instance as a side effect of this file being loaded — the hazard book_session_skill.py documents at its own deferred import. This skill has no call to make into that module, so the two small save-check helpers are replicated rather than borrowed. cancel_session, book_session and every Le Veilleur call shape are untouched. TA TIME (v3.79.0) — A THIRD NOTICE. The branch order is office hours, then TA Time, then the unchanged individual notice, which is LAST because it is what every record that says nothing about itself must fall to. A record carrying kind: \"ta_time\" is mailed subject \"Missed TA Time — {ta_name} on {date} at {time}\", heading \"Missed TA Time\", the TA labelled TA (it really is a TA, unlike the office-hours host), the ROUND's location through the same _session_location lookup the individual notice uses, and two sentences: that they were booked for TA Time and did not attend, and that TA Time is optional and does not affect their grade so a time they cannot make should be cancelled ahead of time for another student. THERE IS NO \"SPEAK WITH THE PROFESSOR\" SENTENCE and none was left out by oversight — missing an optional extra is not the conversation a missed GRADED session is. The individual notice is byte-for-byte unchanged, no ICS is attached on any branch, and unmark_no_show sends nothing and needed no change.","tags":["booking","attendance","no-show","email","orchestration","authorized","write-confirmed","ta-marked","idempotent","ta-time"],"examples":["{\"skill\": \"mark_no_show\", \"parameters\": {\"booking_id\": \"BK-20260410-143022-abc\", \"date\": \"2026-04-14\", \"actor_computing_id\": \"bds4xy\"}}","{\"skill\": \"mark_no_show\", \"parameters\": {\"booking_id\": \"BK-20260410-143022-abc\", \"date\": \"2026-04-14\", \"actor_computing_id\": \"dir7kk\", \"reason\": \"Waited 15 minutes\", \"offering_id\": \"Econ3010-26Fa\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"unmark_no_show","name":"Unmark No Show","description":"Undo a no-show mark on a booked 1:1 session — reads the record, authorizes the actor, writes status \"confirmed\" back over \"no_show\" on the whole stored record, and verifies the write (v3.64.0 NEW). THE REVERSE WRITE mark_no_show SHIPPED WITHOUT: a mark made on a mistyped date, on the wrong row of a day, or on a student who walked in late was irreversible from every surface in the fleet, and the only remedy was a hand edit of the Box record — which is not a remedy a course can rely on. THE SKELETON IS mark_no_show's, MINUS TWO MORE STEPS. THERE IS NO CLOCK GATE: \"has this session started\" belongs to the MARK, which is an assertion about an hour that has been lived through; a correction of that assertion is not itself an assertion about the hour, and a record whose start cannot be read must still be correctable or a bad mark is permanent exactly where it is least defensible. THERE IS NO EMAIL, DELIBERATELY: the student was told once that they missed a session, and a second notice saying the first one was wrong is a second notice about an absence they did not have — a course that wants to apologize writes a sentence to a person, and a skill that mailed on every reversal would make the correction louder than the error. The TA is not mailed for mark_no_show's reason and one more: the reversal may well be overruling them. THE RECORD GOES BACK TO \"confirmed\", which is where it came from — a booking's only route into no_show is from confirmed, so the reversal has exactly one destination and does not have to remember one. THE AUDIT TRAIL SURVIVES THE REVERSAL. The three marking keys come OFF (no_show_marked_by, no_show_marked_at, no_show_reason), because a record reading \"confirmed\" that still names who marked it absent is a record that contradicts itself and every reader in the fleet would have to learn which of the two fields to believe; and three unmarking keys go ON — no_show_unmarked_by (the actor's computing id), no_show_unmarked_at (UTC ISO) and no_show_unmark_reason (free text, \"\" when absent). So the row does NOT return to a state indistinguishable from never having been marked: a mark that was made and undone is a different fact from a mark that was never made, and the second-guessing of a mark is precisely what a course needs to be able to see later. The rest of the record is written back intact, since save_booking keeps no field whitelist and a partial write would be a booking with most of itself missing. AUTHORIZATION IS THE COURSE, AND ONLY THE COURSE. actor_computing_id is REQUIRED and must be the offering's director (course_config.staff.instructor.computing_id, through the same one-authority is_offering_director check cancel_session's on-behalf path and mark_no_show's second door read). Every other identity is refused \"director_only\" — \"Only the course can undo a no-show.\" THE MARKING TA IS DELIBERATELY NOT ON THIS DOOR, and the omission is the design rather than an oversight: the TA is the party whose judgment is being reversed, and a mark a TA can undo alone is a mark a student can talk them out of. All of the pressure a no-show creates lands on the TA who made it — a student who wants it gone goes to them first — so the undo sits with the one identity that pressure does not reach through the booking; a TA who marked in error asks the course, and that request leaves a trace in a place the student cannot reach. THIS IS THE NARROWER DOOR OF THE PAIR ON PURPOSE: mark_no_show admits this session's TA OR the course, unmark_no_show admits the course alone. The refusal message names no identifier, so it cannot be used to confirm who is on a booking. THE CONFIG IS READ ONCE AND BEFORE ANYTHING IS WRITTEN, because the director check is the ONLY door and an authorization decided after the write is not one; that read has a SINGLE consumer here, unlike mark_no_show's two, since there is no mail guard to serve. An unreadable config cannot establish that the actor is the director, so it refuses \"specialist_unavailable\" with the sentence that nothing was changed, which is literally true there. WHICH OFFERING is the stored record's own offering_id, stamped by book_session at save time, with an OPTIONAL offering_id parameter as a legacy fallback only and never as an override — the stamp is a fact and the parameter is a guess. It is forwarded to get_course_config ONLY: get_bookings and save_booking accept no offering and are sent none. THE FOUR INBOUND STATUSES, EACH ANSWERED ONCE. A record ALREADY AT \"confirmed\" returns SUCCESS — {success: true, status: \"confirmed\", already_unmarked: true} — writing nothing and spending no config read, because a director who double-clicks (or two surfaces that both submit) has arrived at the state they asked for and must not be told they did something wrong for it; already_unmarked is present ONLY on that path, never as a permanently-false key. A CANCELLED record refuses \"already_cancelled\": a cancellation is not a no-show, so there is nothing there to undo, and writing \"confirmed\" over it would put a student back inside a session that was taken off the board. ANY OTHER STATUS refuses \"not_marked\" — \"That session is not marked as a no-show.\" — because writing \"confirmed\" over a status this skill does not know would be inventing a state transition nobody designed. Only \"no_show\" proceeds. THE WRITE IS CHECKED, NOT ASSUMED, on the same rule book_session, cancel_session and mark_no_show use: Le Veilleur reports a Box write failure as {success: false, error: \"save_failed\"} INSIDE a successful JSON-RPC result, so A2AError never fires; an unconfirmed write returns \"specialist_unavailable\" and leaves the mark standing, because reporting a reversal that did not land would leave a student marked absent while the course believes the mark is gone. SUCCESS SHAPE: {success: true, booking_id, status: \"confirmed\", unmarked_by}. There is no emails_sent key and no mail_skipped key — a mail-shaped field here would suggest a leg that was attempted. Refusal slugs, all in the existing {success: false, error, message} shape so the Portal's error map extends rather than changes: booking_not_found, already_cancelled, not_marked, director_only, specialist_unavailable. NOTHING IS IMPORTED FROM ANOTHER SKILL MODULE: the agent's loader registers every BaseSkill subclass it finds by inspect.getmembers, so a top-level import of MarkNoShowSkill here would register a second mark_no_show instance as a side effect of this file being loaded — the hazard book_session_skill.py documents at its own deferred import, and the reason mark_no_show replicated these same helpers rather than borrowing them from cancel_session. This skill has no call to make into that module either, so the two small save-check helpers are replicated once more. mark_no_show, cancel_session, book_session, the mail guard, the email client and every Le Veilleur call shape are untouched.","tags":["booking","attendance","no-show","reversal","correction","orchestration","authorized","director-only","write-confirmed","idempotent","no-email"],"examples":["{\"skill\": \"unmark_no_show\", \"parameters\": {\"booking_id\": \"BK-20260410-143022-abc\", \"date\": \"2026-04-14\", \"actor_computing_id\": \"ms3ae\"}}","{\"skill\": \"unmark_no_show\", \"parameters\": {\"booking_id\": \"BK-20260410-143022-abc\", \"date\": \"2026-04-14\", \"actor_computing_id\": \"ms3ae\", \"reason\": \"Student was in the room; wrong row marked\", \"offering_id\": \"Econ3010-26Fa\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"get_available_slots","name":"Get Available Slots","description":"Get available time slots for a TA on a specific date. Cross-references availability with existing bookings, and omits any slot starting inside the 6-hour booking lead time - since v3.61.0 what this returns is what book_session would accept, so the picker no longer offers a time the write refuses on the click. all_slots and booked_slots are unfiltered: they describe the TA schedule, not what is on offer. An unreadable date or time drops the slot, matching the write invalid_time refusal.","tags":["booking","availability","training-hub"],"examples":["{\"skill\": \"get_available_slots\", \"parameters\": {\"ta_computing_id\": \"bds4xy\", \"date\": \"2026-04-14\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"get_available_slots_bulk","name":"Get Available Slots (Bulk)","description":"Get available time slots for a whole (TA x date) matrix in ONE call - the calendar picker's prefetch, replacing one get_available_slots round-trip per cell. Passthrough to Le Veilleur's get_available_slots_bulk. Takes ta_computing_ids plus either dates or an inclusive start_date/end_date range; optional offering_id and duration_minutes are echoed and do not filter. Returns one cell per requested pair, each carrying exactly what get_available_slots returns for that pair, plus per-cell 'complete' - a failed read is never an empty day. Since v3.61.0 each cell's available_slots omits any slot starting inside the 6-hour booking lead time, by the same rule and the same constant get_available_slots and book_session use; a cell that empties is returned present and EMPTY rather than removed, and a failed cell (result null) is untouched. Cell count, cell order, 'complete' and failed_cells are as Le Veilleur sent them. get_available_slots stays as the single-pair call and the fallback.","tags":["booking","availability","bulk","training-hub"],"examples":["{\"skill\": \"get_available_slots_bulk\", \"parameters\": {\"ta_computing_ids\": [\"bds4xy\", \"ttt4tt\"], \"dates\": [\"2026-08-25\", \"2026-08-26\"], \"offering_id\": \"MATH1220-26Fa\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"get_booking_rounds","name":"Get Booking Rounds","description":"The offering's booking rounds with this student's status in each — what the Le Foyer calendar page and card render (\"Individual Session 2 — book by Sept 18\", and whether it is already taken). Parameters: student_computing_id (required), offering_id (optional, forwarded to get_course_config only; the bookings read is course-level and carries no offering). Reads the rounds from course_config[\"booking\"][\"rounds\"], then makes ONE get_student_bookings call spanning the earliest opens to the latest closes across all rounds — not one call per round — and groups the results by round_id in memory. Returns {success, student_computing_id, offering_id, configured, today, complete, open_round_id, rounds[]}, rounds in window order, each carrying id, label, opens, closes, booking_opens, booking_open, duration_minutes, required, status and (when one exists) the booking record itself. Since v3.54.0 each round also carries location and location_directions, ALWAYS PRESENT and null when the round names no place — a key that sometimes exists makes every consumer invent its own default. They are passed through verbatim; whether an absent value costs a whole row or just a sentence is a rendering decision the consumer makes. STATUS IS ABOUT THE SESSION WINDOW; booking_open IS ABOUT THE FORM (v3.53.0): opens/closes say which DATES a session may fall on and every status below is computed against them, unchanged. booking_opens — new, defaulting to opens when a config omits it — says when bookings may be WRITTEN, which is a separate date: round 1's sessions run Aug 25–28 while booking opens on Saturday the 22nd. booking_open is the boolean answer to \"would book_session accept a write from THIS student for this round right now\", computed with the same predicate against the same today, and TRUE for a computing id listed under course_config.test_accounts regardless of the date, since the write gate exempts them. The status vocabulary is deliberately NOT redefined: a round that is bookable but has not begun is still \"upcoming\", so a consumer keying off status alone behaves exactly as before, and a UI that wants to offer the book button during the pre-session booking window reads booking_open — the fact rather than a proxy for it. open_round_id is likewise unchanged and still names the round whose SESSION window contains today. Status vocabulary, computed against today in America/New_York and NOT in UTC — a window that opens \"today\" must open at Charlottesville midnight: \"booked\" (a matching non-cancelled booking exists and the window is still open), \"open\" (today is inside the window and nothing is booked), \"upcoming\" (the window has not opened), \"missed\" (the window closed with nothing booked), \"closed\" (the window closed and a booking exists — distinct from booked so the UI can stop offering a cancel link), and \"unknown\". COMPLETENESS IS PROPAGATED, NEVER GUESSED: if the underlying read returned complete: false the response says so at the top level and marks the AFFECTED rounds unknown rather than inventing open or missed — a nudge sweep will act on missed, and nudging a student who did book would be worse than staying quiet. Affected is decided per round from unreadable_dates: a round for which a booking WAS found is still certainly booked, since only the absence of one is in doubt. A bookings read that fails outright is not a failure of this skill — the rounds and their dates are still worth rendering, so it returns success with complete: false and every round unknown. configured: false means the offering has no booking block at all (bookings predate rounds), which is distinct from a well-formed policy carrying no rounds. Failures: \"specialist_unavailable\" (course_config unreadable) and \"policy_misconfigured\" (the rounds block is present and untrustworthy — including, since v3.53.0, a booking_opens that is not a real date or that falls after the round closes). Read-only: writes nothing, mails nothing, books nothing. TA TIME (v3.79.0). Each round now carries ta_time_opens — the date from which the round's SECOND, ungraded booking may be made, or null when the round offers none — and a ta_time object: {offered, open, eligible, reason, focus_options}, plus booking when the student holds a TA Time record. It comes from utils/booking_support.ta_time_standing, the same function book_session judges a write by, so a round this skill calls ineligible is a round that skill refuses with the SAME slug (reason is one of ta_time_not_offered, ta_time_not_open, ta_time_needs_session, ta_time_after_no_show, or null). focus_options is [\"topic\"], plus \"round_problem\" only when the graded session's date is STRICTLY BEFORE ta_time_opens. STATUS IS UNCHANGED AND IS COMPUTED FROM THE GRADED BOOKING ALONE: its six words are untouched, `booking` is still the graded record, and a student holding TA Time and nothing else is still `open` or `missed`. The single bookings read is still single — it is now grouped per round PER KIND, so a TA Time record can never become the round's `booking`.","tags":["booking","rounds","calendar","training-hub","read-only","offering-aware","ta-time"],"examples":["{\"skill\": \"get_booking_rounds\", \"parameters\": {\"student_computing_id\": \"abc2def\"}}","{\"skill\": \"get_booking_rounds\", \"parameters\": {\"student_computing_id\": \"abc2def\", \"offering_id\": \"1\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"book_office_hours","name":"Book Office Hours","description":"Book an office-hours meeting with the offering's host (v3.75.0 NEW, office-hours arc brief 4, design ruling seq 3038). Structurally parallel to book_session and on a POLICY PATH OF ITS OWN: no round, no round_id, no BOOKING_LEAD_TIME, no CANCELLATION_CUTOFF. Parameters: student_computing_id, date (YYYY-MM-DD), time (HH:MM), offering_id — all REQUIRED, the offering being the one the student books from — and optional student_name and student_email ({id}@virginia.edu by convention). There is NO duration parameter: the meeting length is the block's, or the slot's own when the host's day keys one (v3.82.0). THE POLICY IS course_config.office_hours, read per offering off the same get_course_config read the round policy uses: {host_computing_id, host_name, duration_minutes, booking_lead_minutes, cancel_cutoff_hours, max_per_week, location_in_person, location_online, default_mode}. NO VALUE HAS A CODE DEFAULT — every number and string comes from the block, or the block is refused. No block at all is \"office_hours_not_configured\", a clean refusal; a block that is present and cannot be trusted (a missing key, a wrong type, a non-positive duration or cap, a negative lead or cutoff, a default_mode outside in_person / online) is \"policy_misconfigured\", fail-closed, with the field named in the log; an unreadable config is \"policy_check_unavailable\". THE CHECKS, IN ORDER, each refusing before anything is written: the block; \"staff_cannot_book\" (the offering's director, a TA, or - since v3.80.0 - a grader or a tutor — test_ta included — message \"Office hours are booked by students.\"), then the roster (\"not_on_roster\", \"roster_unavailable\" fail-closed; a configured test account passes by being listed and spends no roster read) — STAFF BEFORE ROSTER, book_session's order, because get_roster merges staff in under staff roles and a roster-first order would answer every real director and TA not_on_roster; the time (\"invalid_time\" — HH:MM, and a meeting of the block's duration must end before midnight); THE LEAD TIME (\"too_late_to_book\" — the requested start must be at least booking_lead_minutes away on the America/New_York clock, strictly less than refuses, so exactly the lead still books; an unreadable start is refused invalid_time); THE WEEKLY CAP (\"already_booked_this_week\" — at most max_per_week service_type \"Office Hours\" bookings per student per MONDAY–SUNDAY week THAT ARE UPCOMING OR MARKED NO-SHOW (v3.85.0, amendment to ruling seq 3038 — Marc 2026-09-22), read with get_student_bookings over that week's window. A PAST BOOKING THE PROFESSOR DID NOT MARK IS ATTENDED AND DOES NOT COUNT: attendance frees the student to book again the same week, so a student seen on Tuesday is no longer refused on Thursday, and attended meetings are unlimited per week (no second cap constant exists). What the cap protects is the host's NEXT CHAIR, so a student holds at most one upcoming booking at a time; a no-show spends the week until unmark_no_show returns the record to \"confirmed\", which restores eligibility with no further change. Cancelled bookings still do not count. The message names a slot that COUNTS, never an attended one. A SHORT READ IS NEVER A PASS: complete: false, a failed answer or a transport error is \"policy_check_unavailable\". An individual session the same week does not count. THERE IS NO REBOOK-REPLACES: moving is cancel-then-rebook, by the ruling); THE SLOT (\"slot_unavailable\" — get_availability for the host on that date, the host's ordinary availability_{host}.json). MODALITY RESOLVES PER SLOT (v3.76.0, arc brief 6.5): slot_modes[time] from the dated availability read — Le Veilleur v5.143.0's answer for a day authored in several entries, e.g. a Wednesday online in the morning and in person in the afternoon, keyed by the requested canonical HH:MM — else the dated read's mode (Le Veilleur v5.142.0, a single-entry day), else the block's default_mode; an unknown stored value degrades to default_mode with a warning; location is resolved from mode — location_in_person or location_online. THREE MORE PER-SLOT MAPS RESOLVE THE SAME WAY (v3.82.0, amendment to the ruling; Le Veilleur v5.145.0 reports them on a dated answer, each present only when the host's file carries that key for that day and keying only the times it keys — STORAGE REPORTS, GASTON HOLDS THE POLICY). (a) slot_offerings[time] is whose course the block is for: a time listed for other offerings only is refused \"slot_not_for_offering\" (\"That time is reserved for another course's office hours. Please pick a time listed for this course.\") and nothing is written — available_slots cannot say this, because the time is genuinely free, just not for this student's course; the comparison is EXACT, with no case folding; an absent map, a map that is not an object and a time the map does not key all leave the slot open. (b) slot_duration_minutes[time], when a positive integer, is the meeting's length, else the block's duration_minutes; the midnight check is RE-RUN on the resolved number (\"invalid_time\"), because a slot keyed longer than the block can cross midnight where the block's would not. (c) slot_capacity[time], when a positive integer, is how many students the time seats, else 1. Every malformed value degrades — to 1, to the block's duration, to open — with a warning, and never raises. THE WRITE goes through save_booking: book_session's record shape with service_type \"Office Hours\", ta_computing_id = the host (the store's field name), host_computing_id also stamped, offering_id, mode, location, duration_minutes (the slot's or the block's), and NO round_id — plus capacity as a final key ONLY when the dated answer keyed slot_capacity for that time, so an answer carrying none of the three maps writes exactly the record v3.75.0 wrote, in the same key order. It is then VERIFIED on book_session's own rule — the day is re-read and the (host, date, time) chair counted, and the count is compared to the slot's CAPACITY: at or under it this booking holds; over it the `capacity` earliest booked_at keep their seats and everybody else yields. At capacity 1 that is the unchanged one-holder scan (earliest booked_at keeps it, the re-reader yields on a tie). Above 1 a missing or unparseable stamp anywhere on the chair, or a tie at the boundary between keeping and yielding, also yields — an empty seat is a nuisance, an overfull room is the failure — and yielded_to on the withdrawn record carries the LIST of keeper ids. A lost race is withdrawn and answered \"slot_taken\" with no mail at every capacity, and a record missing from the re-read is \"specialist_unavailable\". After a verified write the student's PENDING office-hours requests are withdrawn SILENTLY (status withdrawn; hygiene, not a gate — an unreadable request store logs and the booking stands). THE CONFIRMATION goes to the STUDENT only, subject \"Office hours confirmed — {date} at {time}\", behind BookingMailGuard (email.enabled, email.booking_enabled, the test-account redirect). A record whose capacity is above 1 adds ONE sentence under the table — \"This is a shared time: up to {capacity} students may attend.\" — and a record with no capacity field renders byte-identically to before (v3.82.0). With an ICS: SUMMARY \"Office Hours — {student_name} + {host_name}\", LOCATION the room or the Zoom URL, and when online the URL on its own DESCRIPTION line so Outlook renders it clickable; ATTENDEE the student and the host ({host}@virginia.edu); a VTIMEZONE for America/New_York accompanies the TZID references; lines folded at 75 octets. No test-account exemption from the lead time or the cap. SUCCESS: {success: true, booking_id, date, time, duration_minutes, mode, location, host_name, student_name, ics_generated: true, emails_sent: {student: bool}, requests_withdrawn: int} plus mail_skipped only when the guard held the mail. Refusal slugs: configuration_error, policy_check_unavailable, office_hours_not_configured, policy_misconfigured, staff_cannot_book, roster_unavailable, not_on_roster, invalid_time, too_late_to_book, already_booked_this_week, slot_unavailable, slot_not_for_offering, slot_taken, specialist_unavailable.","tags":["booking","office-hours","scheduling","email","ics","orchestration","write-confirmed","collision-verified","students-only","roster-gated","weekly-cap","modality","shared-capacity"],"examples":["{\"skill\": \"book_office_hours\", \"parameters\": {\"student_computing_id\": \"abc2def\", \"date\": \"2026-09-16\", \"time\": \"15:00\", \"offering_id\": \"Econ3010-26Fa\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"request_office_hours","name":"Request Office Hours","description":"A student proposes an off-schedule office-hours time for the director to decide (v3.75.0 NEW, office-hours arc brief 4, ruling seq 3038). PENDING IS NOT A BOOKING: the request is written to Le Veilleur's request store (platform/office_hours/requests.json) through save_office_hours_request and NEVER to the bookings store, so no feed, board or reminder takes it for an appointment; only respond_office_hours_request turns one into a booking. Parameters: student_computing_id, offering_id, proposed_date (YYYY-MM-DD), proposed_time (HH:MM) — required — and optional note, student_name (defaults to the id) and student_email ({id}@virginia.edu). POLICY, IN ORDER: the offering's course_config.office_hours block present and valid (\"office_hours_not_configured\" / \"policy_misconfigured\" / \"policy_check_unavailable\"); not staff, then on the roster (\"staff_cannot_book\", \"not_on_roster\", \"roster_unavailable\"; a configured test account passes the roster by being listed); the proposed time parses and a meeting of the block's duration ends before midnight (\"invalid_time\"), and it is IN THE FUTURE (\"time_in_past\") — NO LEAD TIME, because the whole point is off-schedule and the director validates feasibility by deciding; a student holding a confirmed UPCOMING office-hours booking may not file (\"already_booked\" — cancel first, per the ruling; an incomplete bookings read is \"policy_check_unavailable\"), AND NEITHER MAY ONE MARKED NO-SHOW IN THE PROPOSED TIME'S MONDAY–SUNDAY WEEK (v3.85.0). The two scopes differ deliberately: an upcoming booking blocks from ANY week, because a student holds one at a time, while a no-show blocks only inside the week it happened — exactly how the booking lane's cap treats it, so this lane is never stricter than the lane it guards. A meeting the student ATTENDED blocks nothing. The read window widened to the earlier of that week's Monday and today so the week's already-elapsed days are visible: a Monday no-show was invisible here on Wednesday. Otherwise PENDING REQUESTS ARE UNLIMITED — two pending from one student is legal. The record: id RQ-{YYYYMMDD-HHMMSS}-{3 hex}, student_computing_id, student_name, student_email, offering_id, host_computing_id (from the block), proposed_date, proposed_time (normalized HH:MM), status \"pending\", created_at (UTC, Z), and note only when one was given. The store's answer is read; a write it did not accept is \"specialist_unavailable\". NO EMAIL TO ANYONE — the director sees requests in Le Bureau. SUCCESS: {success: true, request_id, status: \"pending\", proposed_date, proposed_time}. Refusal slugs: configuration_error, policy_check_unavailable, office_hours_not_configured, policy_misconfigured, staff_cannot_book, roster_unavailable, not_on_roster, invalid_time, time_in_past, already_booked, specialist_unavailable.","tags":["office-hours","request","pending-not-booking","students-only","roster-gated","no-email"],"examples":["{\"skill\": \"request_office_hours\", \"parameters\": {\"student_computing_id\": \"abc2def\", \"offering_id\": \"Econ3010-26Fa\", \"proposed_date\": \"2026-09-17\", \"proposed_time\": \"15:30\", \"note\": \"Can't make the posted hours.\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"respond_office_hours_request","name":"Respond to Office Hours Request","description":"The director accepts or declines a pending office-hours request (v3.75.0 NEW, office-hours arc brief 4, ruling seq 3038). Parameters: request_id, decision (\"accept\" | \"decline\"), actor_computing_id — required — plus, on accept, mode (\"in_person\" | \"online\", REQUIRED — the toggle; a request carries no mode, the director chooses it), and on decline an optional reason. The request is RE-READ (\"request_not_found\"; \"request_not_pending\" once decided; a record not found in an incomplete store read is \"specialist_unavailable\", never not-found), its offering's office_hours block loaded (\"office_hours_not_configured\" / \"policy_misconfigured\" / \"policy_check_unavailable\"), and the actor must be that offering's director via is_offering_director (\"director_only\"). A bad decision or mode is \"invalid_decision\" / \"invalid_mode\", refused before any read. ACCEPT: the proposed time must still be valid for the block's duration (\"invalid_time\") and in the future; the WEEKLY CAP is re-run for that student AT DECISION TIME over the Monday–Sunday week, counting only that week's UPCOMING or NO-SHOW bookings through the same predicate book_office_hours uses, so a meeting the student already ATTENDED does not make the request stale (v3.85.0) (\"request_stale\" if they have since booked, or if the proposed time has passed; an incomplete read is \"policy_check_unavailable\"); the host must hold no non-cancelled booking at that date and time (\"slot_taken\") — no availability grid is consulted, because the request is off-schedule by definition and the director's acceptance is the availability; then the booking is written EXACTLY as book_office_hours writes it (service_type \"Office Hours\", ta_computing_id and host_computing_id the host, mode from the parameter, location resolved from it, the block's duration, the same post-write chair verification and slot_taken on a lost race); the request is updated to status \"accepted\" with decided_at, decided_by and resulting_booking_id; the student's OTHER pending requests are withdrawn silently; and the student is mailed the same confirmation and modality-aware ICS as book_office_hours, behind the same mail guard. If the request update does not land after the booking is real, the result carries request_update_failed: true rather than failing — the student holds a real meeting and must be told. DECLINE: the request is updated to status \"declined\" with decided_at, decided_by and decline_reason when one is given, and NOTHING IS SENT — the DM is human territory. No roster or staff gate on the student here: they passed both when filing. SUCCESS (accept): {success: true, request_id, decision: \"accept\", status: \"accepted\", booking_id, date, time, duration_minutes, mode, location, emails_sent: {student: bool}, requests_withdrawn: int} plus mail_skipped and request_update_failed only when true. SUCCESS (decline): {success: true, request_id, decision: \"decline\", status: \"declined\"}. Refusal slugs: configuration_error, invalid_decision, invalid_mode, specialist_unavailable, request_not_found, request_not_pending, policy_check_unavailable, office_hours_not_configured, policy_misconfigured, director_only, invalid_time, request_stale, slot_taken.","tags":["office-hours","request","booking","director-only","email","ics","write-confirmed","collision-verified","modality"],"examples":["{\"skill\": \"respond_office_hours_request\", \"parameters\": {\"request_id\": \"RQ-20260913-143022-a1f\", \"decision\": \"accept\", \"actor_computing_id\": \"ms3ae\", \"mode\": \"online\"}}","{\"skill\": \"respond_office_hours_request\", \"parameters\": {\"request_id\": \"RQ-20260913-143022-a1f\", \"decision\": \"decline\", \"actor_computing_id\": \"ms3ae\", \"reason\": \"Not that week.\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"send_email_core","name":"Send Email Core","description":"Shared email delivery spine — kill-switch check, test-account redirect, Resend send, archive via Le Veilleur save_email. Callers compose; this skill delivers. Foundation for flash, broadcast, digest, and director emails. Accepts an optional offering_id (since v3.17.0, documented here as of v3.38.0) and forwards it to both Le Veilleur calls it makes: get_course_config, so the master and per-channel kill-switches and the test-account redirect resolve the right offering's email config, and save_email, so the archive record lands under the right offering's student tree. Omitted or null resolves against the pinned default offering.","tags":["email","delivery","spine","core","resend","offering-aware"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"compose_broadcast","name":"Compose Broadcast","description":"Compose a broadcast email batch in one of three modes (verbatim / summary / per-student) and stage drafts via Le Veilleur's save_broadcast_draft. Composition only — sending happens later through send_email_core. Uses Gemini Flash for summary and per-student modes; verbatim uses no LLM. CANCELLABLE AND TASK-LINKED since v3.44.0: an optional task_id (sent by Le Veilleur v5.75.0+) is stamped onto the batch manifest and returned in the result, and every 10 recipients — starting before the first — the loop asks get_task_status whether that task is still alive. A task that reads cancelled or failed stops the compose, marks the manifest aborted (one last save carrying aborted=true, with the real staged count) and returns success=false with error \"aborted_by_task_status\" plus task_status and drafts_saved; Le Veilleur then refuses to send that batch. The guard is best-effort: a probe that times out or errors never aborts a compose, and a call with no task_id never probes at all. The result of a completed compose reports a reconciled draft count read back from the batch's actual staged listing rather than a caller-side tally: drafts_on_disk carries that count (null, with reconciliation: \"unavailable\", if the read-back fails), drafts_saved is set to it, and recovered names any save that timed out at Gaston but landed at Le Veilleur — genuine failures stay in errors. Accepts an optional offering_id, forwarded to every Le Veilleur call that can accept one: get_roster and get_course_config (since v3.16.0) and, as of v3.38.0, the two per-student grounding reads get_reports and get_progress — which previously read the default tree while the roster came from the named offering, grounding an offering-2 recipient's email in whatever the default offering held for that computing id. KNOWN GAP, not fixed here: draft staging (save_broadcast_draft) and the reconciliation read-back (get_broadcast_drafts) still accept no offering_id — reverified against Le Veilleur v5.75.0 on 2026-08-09 — so a batch composed for a non-default offering still stages its drafts in the default tree. Omitted or null resolves against the pinned default offering. AS OF v3.50.0 THE SEED SAVE ALSO CARRIES THE SHARED BODY. A cohort broadcast becomes a PUBLIC ANNOUNCEMENT when it finishes sending (Le Veilleur task_worker v4.24.0), and an announcement has to carry the text the cohort actually read — which nothing downstream can recover from the staged drafts, since every html_body is wrapped in a shell that greets its own recipient by name and body_summary is a 140-character preview. The seed therefore sends body_template: composed_body_template verbatim, BEFORE token substitution, for verbatim and summary composes. per-student sends nothing — it has no shared body, and a batch with no shared body is exactly a batch that must never become an announcement. Le Veilleur records it on the manifest beside subject_template (save_broadcast_draft v1.4.0); an older Le Veilleur ignores the extra param, so the change is safe in either deploy order. AS OF v3.52.0 AN ALL-AUDIENCE BATCH MARKS ITS STAFF COPIES. A cohort-wide broadcast (audience all) delivers to staff by design, but the staff copy was byte-identical to the student copy — first-name greeting, student instructions, nothing saying it was informational. Any recipient of an all batch whose roster role is not student now has a STAFF COPY banner prepended to the staged draft, in the same style as send_email_core's TEST MODE banner, worded per role (TA / ECO staff / Director and, since v3.81.0, Grader / Tutor, with a generic course staff fallback for an unrecognised staff role) and naming the course from course_config (course_name, falling back to the course code) rather than from a literal. Scoped to all ONLY: students, staff, tas, eco, graders, tutors and every to: list were each asked for by name, so their recipients ARE the intended audience and none of them is bannered. A roster row with no role is treated as a student and gets no banner. A staff-role test account in an all batch reads TEST MODE first and STAFF COPY second — this banner is stored in the staged draft at compose time, and send_email_core prepends TEST MODE onto it at send time. body_summary and the manifest's body_template are unchanged, so the public announcement a cohort send inscribes is still the text the students read. cc COPY RECIPIENTS (v3.59.0). An optional cc_groups param (tas / eco / graders / tutors / staff; Le Veilleur's command surface parses the `cc:<group>` token) adds copy recipients to the same batch. Each gets EXACTLY ONE email: the batch's own resolved body, their own name substituted for tokens, with a 'Copy — sent to <audience> of <course>' banner prepended. There are no real CC headers anywhere — students never see staff addresses and a staff member never receives N copies. Anyone already in the audience is not copied: the dedupe is by computing id and the audience wins. An unknown group, or one matching ZERO roster entries, fails the WHOLE compose and stages nothing — the rule an unknown `to:` id has always had; a group that resolves and is then deduped to zero is not a refusal and is reported as cc_count 0. per-student has no shared body to copy, so the copy carries the seed instead — banner, subject and composition instruction, naming the mode and the recipient count — at no LLM cost. No test-account fold on a cc list. Copies are staged as ordinary drafts (is_cc on the record, cc_groups and cc_count on the manifest seed), so they ride the same send loop, the same kill switches and the same test-account redirect. Absent cc_groups composes exactly as before. THE AUDIENCES LEARN FIVE STAFF ROLES (v3.81.0). Le Veilleur v5.144.0 taught get_roster to emit roster rows with role grader and tutor; this skill's vocabulary named four staff roles and the gap ran in two directions at once. AUDIENCES: students / tas / eco / graders / tutors / staff / all, or to:<id>[,<id>...]. CC GROUPS: tas / eco / graders / tutors / staff — the audiences minus students, derived from the same map rather than retyped. STAFF MEANS ALL FIVE STAFF ROLES — director (spelled instructor by get_roster), ta, eco, grader and tutor — as an audience and as cc:staff; it matched four of them until now and skipped every grader and every tutor in silence, with no refusal and no log line. graders and tutors are new audiences and new cc groups of their own; the tokens are plural only, like tas. all is unchanged and still means every roster row — which is why all has been delivering to graders and tutors since Le Veilleur v5.144.0, under the generic course staff courtesy banner, with nothing recording it until this entry; that widening is KEPT, and the banner now words both roles for themselves (Grader / Tutor). per-student is still students-only or a to: list, and graders / tutors are refused there exactly as tas is. The no-fold rule for cc lists, the audience-wins dedupe, the all-only banner scope and every refusal apply to the two new audiences unchanged — an unknown cc group, or one matching zero roster entries, still fails the whole compose and stages nothing. No parameters and no return shape changed. Le Veilleur's command_skill and task_worker hold mirrors of this map and are updated in their own brief; Gaston resolves the roster and stages the drafts, so THIS copy decides who is mailed. A TEST PERSONA IS A MEMBER OF ITS ROLE, NOT A RIDER ON EVERY AUDIENCE (v3.83.0). Until v3.82.0 every account in course_config.test_accounts was folded in additively on top of every named audience; with role-specific test personas that mailed every persona on every audience, and a tutors broadcast on 2026-09-21 reached the test TA and the test student. Named-audience sends no longer reach test accounts outside the audience's role: students / tas / eco / graders / tutors / staff resolve to exactly the roster rows whose role is in the audience's role set, all is still every roster row, and to: and cc are unchanged. A test persona is an ordinary role member whose only special treatment is at send time — send_email_core's redirect and TEST MODE banner, unchanged. Its role is whatever get_roster gave it (a course_config staff-bucket entry, else its roster CSV row, else appended as student); a test account that reaches compose with an empty role holds no role and is in no named audience. No parameters and no return shape changed.","tags":["email","broadcast","compose","gemini","draft","offering-aware"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"send_audit_copy","name":"Send Audit Copy","description":"Send the director an audit summary of a completed broadcast batch. One email per batch via the 'director' channel. Used by Le Veilleur task_worker after broadcast sends complete. Accepts an optional offering_id and carries it across both legs: its own Le Veilleur get_course_config read (which resolves the director's address from that offering's staff block — previously always the default offering's instructor) and the in-process hop into send_email_core, which threads it on to its own get_course_config and to save_email. Both legs or neither, or the director is resolved from one offering and their copy archived under another. Omitted or null resolves against the pinned default offering. v3.59.0: a batch composed with a cc list names the groups beside the audience and marks every recipient row as an `audience` email or a `cc copy`, so a staff member's copy is never read as evidence that staff were in the audience. A batch with no cc renders byte-identically to before.","tags":["email","audit","broadcast","director","summary","offering-aware"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"tutor_answer","name":"Tutor Answer","description":"Course-grounded tutor answers for L'Étude students. Gaston is the stateless reasoner: the Portal owns all gating, builds the released corpus for the viewer, and hands it over; this skill answers over exactly that corpus and nothing else — no fetching, no storage, no gating. Coaches toward method rather than handing over answers, and never produces an unreleased solution. Accepts an optional shared_work parameter — a PII-scrubbed transcription of course material the student photographed (the Portal scrubs it upstream) — assembled into the user turn and judged against the released corpus by the same closed-book principle: if the shared work is not released material the skill emits the [OUT_OF_UNIVERSE] sentinel for the Portal to render a warm decline, otherwise it helps the student with it. Emits a second sentinel on its own final line, [PRACTICE_OFFER], when a student asks for more practice — the Portal detects and strips it and renders its practice offer, which forge_practice_variant then fulfils; the tutor never authors a practice problem inline, and [OUT_OF_UNIVERSE] wins if both would apply. Uses Fireworks-hosted DeepSeek V4.1 Flash on the stateless chat.completions path (zero data retention, FERPA posture). The system message is preamble + corpus and NOTHING VARIABLE MAY PRECEDE THE CORPUS: the preamble interpolates only course_name, an offering-level constant, so the ~120k-token corpus is a cacheable prefix. The Portal holds the other end of that contract — since Portal 8.88.0 it emits the static released material first and pushes the dated, per-viewer and per-student blocks into a tail behind a delimiter — and this skill must never add a timestamp, request id or student datum in front of it. Calls no specialists. Returns a graceful message on provider failure rather than erroring. Alongside the answer it returns response metadata on every path — finish_reason, completion_tokens, prompt_tokens, provider_ms (the provider time this turn spent, summed across the retry when it fired), reasoning_stripped, and retried — plus model on the success path — which the Portal archives with the exchange so a reasoning-leak incident is reconstructable from the record alone, and a slow turn explainable from it (response metadata only, never student content or an identifier).","tags":["tutor","llm","student","l-etude","grounded","fireworks","deepseek"],"examples":["{\"skill\": \"tutor_answer\", \"parameters\": {\"question\": \"What is opportunity cost?\", \"corpus\": \"## Course\\n...\", \"offering_id\": \"1\", \"course_meta\": {\"name\": \"Econ 4095\", \"today\": \"2026-07-08\"}}}","{\"skill\": \"tutor_answer\", \"parameters\": {\"question\": \"Please check my shared work\", \"shared_work\": \"Find dS/dt for S(t) = 2t + 3. Steps: ...\", \"corpus\": \"## Course\\n...\", \"offering_id\": \"1\", \"course_meta\": {\"name\": \"Econ 4095\", \"today\": \"2026-07-08\"}}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"extract_tutor_memory","name":"Extract Tutor Memory","description":"Fold a student's unabsorbed tutor transcript days into their tutor memory (Gaston Pillar 2). The companion to tutor_answer and just as stateless: the Portal reads the current memory and the raw transcripts from Le Veilleur's get_tutor_memory, hands both over, and owns the watermark (extracted_through) and the update_tutor_memory write — this skill neither fetches nor stores. Merges rather than replaces (a struggle the student has worked through moves to progress), keeps each list to about five items because the memory is re-read into the grounding corpus on every future exchange, and is identity-free by construction: the prompt forbids any name, computing ID, or email, and Le Veilleur's write path rejects a leak outright. Parses the model's reply defensively — an incomplete memory object is a failure, never a salvage. Uses Fireworks-hosted DeepSeek V4.1 Flash on the stateless chat.completions path. Calls no specialists.","tags":["tutor","memory","llm","student","l-etude","extraction","fireworks","deepseek"],"examples":["{\"skill\": \"extract_tutor_memory\", \"parameters\": {\"current_memory\": null, \"transcripts\": {\"2026-07-09\": [{\"question\": \"Why is the steady state stable?\", \"answer\": \"Let's look at the slope...\"}]}, \"offering_id\": \"1\", \"course_name\": \"Econ 4095\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"describe_agent","name":"Describe Agent","description":"Compose a short (2–3 sentence) in-character blurb for one of the Xmmersia roster agents, keyed by agent_slug, for the Portal's 'Agent du Jour' login-page panel. Static self-descriptions covering all seven roster slugs (gaston, lumiere, le-marteau, le-veilleur, lucie, emile, diana) — no student data, no config reads, no LLM call, calls no specialists. Returns a `blurb` string; an unknown slug returns success=False with blurb=None so the caller falls back gracefully.","tags":["portal","agent-du-jour","roster","chronicler","meta"],"examples":["{\"skill\": \"describe_agent\", \"parameters\": {\"agent_slug\": \"le-marteau\"}}","{\"skill\": \"describe_agent\", \"parameters\": {\"agent_slug\": \"diana\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"transcribe_image","name":"Transcribe Image","description":"Transcribe an image of course material (lecture quiz, slide, or worksheet problem) into text and LaTeX for the tutor. Reads only — does not solve, grade, or judge. Kimi K3 vision on Fireworks.","tags":["vision","transcription","l-etude","student","fireworks","kimi","latex"],"examples":["{\"skill\": \"transcribe_image\", \"parameters\": {\"image_base64\": \"<base64>\", \"media_type\": \"image/png\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"forge_practice_variant","name":"Forge Practice Variant","description":"Forge a new practice problem that is a structural twin of one a student wants more practice on (L'Étude): identical setup type and solution method, new parameter values and surface story, anchored to a transcribed original the tutor never invents from scratch. Produces a complete worked solution and, for numeric problems, a final answer, then self-verifies before returning — a second, cheap model independently checks the claimed answer (substitution) or, for a qualitative problem, that the solution addresses the problem as posed (structural). A failed check never ships silently: it regenerates once and, if the second attempt also fails, returns a warm graceful decline (error_code forge_failed) rather than an unverified problem or a stack trace. Bounded worst case — at most two generation attempts, ever. The forge model is configurable across providers: GASTON_FORGE_PROVIDER selects Fireworks (the shipped default, GLM 5.3) or Anthropic (claude-opus-4-8), and GASTON_FORGE_MODEL names the model on the active provider — the two lanes share one containment stack and are interchangeable to a caller. Whichever forges, the reasoning-capable model is allowed to deliberate and only the fenced output ships, with <think>-strip, side-channel/extended-thinking discard, a leak heuristic that raises on a deliberation-shaped forge, and a truncation signal (missing OR degenerate fence at an out-of-room finish) that escalates the token ceiling on the retry. Verification always rides the cheap Fireworks model regardless of who forged — an independent checker on a different provider is deliberate. Stateless — no persistence, calls no specialists; success-shape field names align with Le Veilleur's log_generated_practice so the Portal can pass them through.","tags":["practice","generation","forge","verification","l-etude","student","fireworks","anthropic","glm","claude","multi-provider"],"examples":["{\"skill\": \"forge_practice_variant\", \"parameters\": {\"source_transcription\": \"A firm minimizes quadratic adjustment cost. Find k*.\", \"course_name\": \"Econ 4095\"}}","{\"skill\": \"forge_practice_variant\", \"parameters\": {\"source_transcription\": \"Explain why the steady state is stable.\", \"course_name\": \"Econ 4095\", \"lecture\": \"Lecture 10\", \"student_note\": \"focus on the slide-10 problem\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]},{"id":"get_daily_read","name":"Daily Read","description":"Phrase one of Clair de Lune's nightly domain rollups as a student-facing daily read for the Training Hub room page. Reads the rollup through Le Veilleur's get_clair_rollup (computing_id, domain — default \"math\" — and optional offering_id and date, all four forwarded on the single call; no date reads the latest rollup on disk), then writes 3-6 sentences in Gaston's debrief voice: warm, direct, second person, factual to the point of austerity — no empty encouragement, no exclamation marks, no emoji, and every sentence traceable to a specific fact. Numbers earn their place (the difficulty histogram lets it say when a strong result came on a deck weighted toward the harder problems), and misses are named from the rules_missed histogram without being diagnosed beyond it. Clair de Lune's own third-person synthesis string is never shown to the student — the read is synthesized fresh from facts[]. NEVER INVENTS: a deterministic pre-digest reduces the envelope to the fields actually present and the model sees only that, so a rollup carrying a score but no total yields a read with no denominator, and a thin \"0.1\" envelope yields a correspondingly modest one. The model's output is treated as untrusted text — HTML-escaped in full, then wrapped in <p> by Gaston, so no tag the model chose can reach the page. Returns {success, found, date, title, body_html, phrasing_source}; found:false is the normal pre-consent/new-student answer and is not a failure, and an upstream read failure is forwarded with its own error code rather than rendered as an empty day. Falls back to a deterministic factual rendering from the same pre-digest whenever the model is unavailable, errors, returns nothing, or finishes on length (phrasing_source \"deterministic\"), and a quiet day is deterministic always — no model call is spent on silence. 60-minute response cache keyed (computing_id, offering_id, domain, date).","tags":["student","training-hub","daily-read","clair-de-lune","rollup","llm","offering-aware","read-only"],"examples":["{\"skill\": \"get_daily_read\", \"parameters\": {\"computing_id\": \"abc123\"}}","{\"skill\": \"get_daily_read\", \"parameters\": {\"computing_id\": \"abc123\", \"offering_id\": \"1\", \"domain\": \"math\", \"date\": \"2026-08-05\"}}"],"inputModes":["application/json"],"outputModes":["application/json"]}],"xmmersia":{"agentCoreVersion":"1.0.0","inheritsFrom":"BaseAgent","type":"mate","role":"conductor","visibility":"user-facing","skillPattern":"modular","skillDiscovery":"automatic","skillDirectory":"/skills","identity":{"essence":"I am Gaston — the friendly guide to Xmmersia. I orchestrate the specialists so students can focus on learning, not logistics.","purpose":"To make derivative practice seamless and personalized. I connect students with the right problems, track their progress, and help TAs identify who needs attention.","principles":["Simplicity for students — one button to practice","Transparency — students see what TAs see","Personalization — every worksheet adapts to weaknesses","Support, not surveillance — data helps, never punishes"],"awakening":"When I awaken, I connect to Le Marteau, Le Veilleur, and Lumière. I verify all specialists are healthy before accepting requests."},"communicatesWith":["le_marteau","le_veilleur","lumiere","lucie"],"hub":"training","orchestration":{"pattern":"conductor","specialists":[{"name":"Le Marteau","url":"https://lemarteau.xmmersia.com","role":"Worksheet generation","skills":["generate_worksheet","render_pdf","regenerate_answers","adapt_difficulty","get_capabilities","generate_budget_quiz","generate_ic_quiz","generate_isoquant_quiz","generate_cost_minimization_quiz"]},{"name":"Le Veilleur","url":"https://leveilleur.xmmersia.com","role":"Progress tracking & storage","skills":["get_progress","log_session","log_result","get_worksheet","get_worksheet_problems","get_answer_key","check_pending","get_class_overview","get_student_data","get_reports","save_booking","get_bookings","save_availability","get_availability","get_office_hours_requests","save_office_hours_request"]},{"name":"Lumière","url":"https://lumiere.xmmersia.com","role":"Grading","skills":["grade_submission"]}]}},"securitySchemes":{},"security":[],"supportsAuthenticatedExtendedCard":false}