Skip to content

Adding a tool

The rules every tool keeps

Staffroom stores and processes no pupil data. A tool you add keeps the same rules as the shipped ones, and the scaffold below already does:

  • Nothing per row is saved. A marks file has one row per script; what a run writes is class-level: per-question statistics, a grade distribution, a chart, a sheet, a key. Never the input's rows with a column added, never a grade per script. A per-script view may be shown on screen (marks folds one away, labelled view only, never saved), but it is not written.
  • No identifiers in, none out. core.csvio.read_table drops any column that looks like a name, an id, a UPN, a candidate number or an email before your code sees the table. Do not add a "names" or "class" input.
  • Rows are scripts, not pupils. Summaries, outcomes and labels count questions or scripts ("6 questions · 10 scripts"), never "pupils".
  • Write to the output folder, not beside the input. The scaffold writes to the folder set in Settings, else <sessions root>/outputs/<tool>/, so trying it on examples/marks_10x.csv never leaves a file in the repository.
  • Example data is invented and starts with a # FAKE DATA line; the pre-commit hook refuses a CSV or XLSX without it.

1. Scaffold

just new my_tool

This creates src/staffroom/tools/my_tool/ with:

File Owns
__init__.py the plugin object: metadata, run() glue (reads/writes files, calls logic)
logic.py pure functions and an Options dataclass; no I/O
panel.py panel_spec(): the declarative UI
cli.py staff my_tool run ... and friends
tests/ tests; shared fixtures come from the root conftest.py

As generated it is a small working tool: it reads a marks file, shows one row per question (how many scripts have a mark, the mean, the highest) and saves that summary, nothing else, to the output folder. It passes ruff and its own tests as generated, so your first commit gets past the hooks. It shows up straight away:

uv run staff list
uv run staff my_tool run examples/marks_10x.csv
# Summarised 6 questions from 10 scripts into ~/TeachingSessions/outputs/my_tool/marks_10x_summary.csv

--out <folder> writes somewhere else for that run. Read the generated files before changing them; they are short, and they show each rule above in place. tools/marks is the reference for a report (tiles, a chart, aggregate-only saving).

Where it sits in the sidebar

The sidebar groups tools by the shape of a teacher's week (GROUPS in core/plugin.py), and the ⌘1–⌘9 shortcuts follow it top to bottom:

Group Shipped tools Shortcuts
Lessons (Library), classroom ⌘K, ⌘1
Sheets & quizzes worksheet, quiz ⌘2, ⌘3
From a program parsons, trace, bugs, fade, pseudocode ⌘4 – ⌘8
After a test marks ⌘9

The Library is the shell's own page, not a tool, so it has ⌘K rather than a number. Four optional class attributes on the plugin say where a tool goes and how it looks; all are read with getattr, so a tool that sets none needs no change: it is listed under Other, after every group, A-Z, with a generic glyph.

class MyToolPlugin:
    name = "my_tool"
    ...
    group = "From a program"  # one of core.plugin.GROUPS, or a name of your own
    glyph = "code"  # a file in shell/assets/glyphs/ (without .svg)
    order = 35  # within the group: lower first; shipped tools use 10 × their WORKFLOW_ORDER place
    hidden = True  # not in the sidebar or on a shortcut; still a CLI tool

group names one of the four groups; a name of your own makes a new group after them. glyph picks one of the bundled monochrome glyphs (books, screen, page, question, blocks, table, bug, taper, code, chart, tool); the shell paints it in the theme's colours, so the tool stays Qt-free. To add a glyph, drop a 24×24 line drawing that uses currentColor for its stroke into shell/assets/glyphs/. order places the tool among the shipped ones in its group (35 puts it between parsons and trace in From a program). hidden is for reference and developer tools: staff list still shows them, marked [hidden], and staffroom --tool <name> still opens them for a screenshot.

2. Write the logic

Put the real work in logic.py as functions over Table (see core/table.py). Keep them pure so tests are one-liners. What they return for saving is class-level, one row per question (or per grade, per topic), never one per script:

def summarise(table: Table, options: Options) -> Table:
    rows = []
    for question in question_columns(table.columns, options.prefix):
        marks = [m for m in table.column(question) if isinstance(m, int | float)]
        mean = round(sum(marks) / len(marks), 1) if marks else None
        rows.append([question, len(marks), mean])
    return Table(["Question", "Answered", "Mean"], rows, fake_data=table.fake_data)

Add fields to Options and the form draws itself. Field metadata can set label, help, min, max, step. Supported types: bool, int, float, str, Literal[...], Enum.

3. Declare the panel

panel.py returns a PanelSpec built only from core/panel.py:

PanelSpec(
    title="Marks",
    inputs=(
        FilePicker(key="marks", label="Marks file", chip=True),
        TablePreview(key="preview", source="marks", summary=found, compact=True),
        EditableTable(
            key="boundaries",
            label="Grade boundaries",
            columns=(Column("Grade", "text", 90), Column("Minimum", "float", 110)),
            rows=(("9", 90), ("7", 70), ("4", 40), ("1", 10)),
            compact=True,
        ),
        OptionsForm(key="options", schema=Options),
    ),
    outputs=(
        StatTiles(key="headline"),
        BarChart(key="distribution", label="Grades", category="Grade", value="Count"),
        ResultsTable(key="questions", label="Questions, hardest first"),
    ),
    run_label="Analyse",
    main_layout="row",
)

Input keys become the keys of the inputs dict passed to run(); output keys are what run() must put in RunResult.tables / RunResult.images. Every output here is class-level: the headline numbers, how many scripts reached each grade, and the questions.

The shell lays every panel out as a workbench: the inputs in a column on the left with the run button at its foot (⌘↵ runs it), the outputs on the right. Outputs are main views, shown as tabs in spec order, or sit below them. An ActionTable goes below by default and everything else is a main view; set place="below" on a secondary output (notes, a view-only table) or place="main" on a list that is the point of the tool, as classroom's sessions are. The run log sits behind a one-line Details toggle at the foot of the output pane in every panel, so there is no log output to declare; ctx.log(...) lines land there.

How the run went is not an output you declare. The result strip on top of the output pane shows a tick or a cross, RunResult.outcome (a short line such as "10 questions · seed 42"; the part before · is the headline; without it the strip shows summary), one button per file in RunResult.files and a Folder button. A file's button is named from its file name (stem / stem_key become Sheet / Key); name one yourself with RunResult.labels (labels={chart: "Chart"}). Raise anything the teacher should act on with ctx.warn("…") rather than ctx.log: it is logged as Warning: … and the strip counts it ("3 warnings ▸").

For a tool whose result is a report rather than one view, tools/marks is the reference:

  • StatTiles(key) is a row of headline numbers over the main view (place="top" by default). Fill it with tables[key] = stat_table([Stat("Mean", "75%", "29.9 / 40"), ...]); Stat(..., action=True) marks the one that asks the teacher to act (the only result that takes the accent colour).
  • PanelSpec(main_layout="row") puts the main views side by side, each in a titled card, instead of tabs; they stack when the pane is too narrow.
  • BarChart(key, category=..., value=..., detail=..., detail_format="{:.0f}%") draws bars from tables[key] in the theme's colours with a Table switch; texts[key] is its caption. A file for reports still comes from the tool.
  • ResultsTable(..., bars=(BarColumn("Facility", 1.0),), tag_column="Reteach", hidden_columns=("Note",)) draws an inline bar beside a 0–1 number, tags a row under its first cell (the column itself is not shown), and moves columns into the row's tooltip. collapsed=True on a place="below" table folds it under its label until opened.
  • A wide table that must fit the screen: ResultsTable(..., compact=True, lift_constants=True, mark_changes=True, pin_last=True) sizes columns to their values, lifts a value that never changes into one line above the table, makes a changed value bold and a carried one grey, and keeps the last column at the right edge (trace's completed table). Only the screen changes: the table the tool returns, and what it prints, keep every column.
  • CardList(key, title="Version {Version}", details=("line {Line}", "{Bug}"), badge="What happens", tone_column="Outcome", tones=(("crash", "danger"),), code_lines=(("was", "Original"), ("now", "Changed to"))) shows one card per row of tables[key], its badge always whole at the right (bugs' key). title and details are format strings over the row's columns.

On the input side, FilePicker(..., chip=True) shows the chosen file as a chip; TablePreview(..., summary=fn, flag="MAX", compact=True) replaces the rows with a few Note lines from fn(table) and a Preview rows link; EditableTable(..., compact=True, choice=Choice(...)) draws a two-column table as a grid of label and number cells with a segmented switch above it (its value reaches run() under the choice's key); Choice also works on its own; and OptionsForm(..., fields=(...)) shows only the named fields.

A tool that works on a program takes it with CodeSource(key="source"): a chip naming the file over a numbered, highlighted, read-only view of it, with Paste instead for code in no file. Its value is CodeValue(path, text); turn it into source text with core.codeinput.code_from_inputs(inputs), which also accepts the command line's {"source": Path} or {"pasted": str}. CodeSource(..., choice=Choice(...)) puts a switch about the code between the chip and the code. ResultsTable(..., line_column="Line") links a table whose rows name program lines to the view (trace), as does a CardList with line_column (bugs), TextView(..., code=True) shows an output as numbered code (pseudocode), and PanelSpec(suggest=fn) lets choices follow what was picked or pasted: fn(inputs) is pure and returns {choice_key: value}.

A tool whose file decides what can be chosen says so with PanelSpec(inspect=fn). The shell calls fn(inputs) when the panel opens and whenever an input changes; it returns an Inspection of a Found per input key: notes shown under a file (13 questions · 6 topics · no problems, or the first problem with its line), rows that a Preview link opens, and for a ChipChoice its chips (a bank's own topics, ChipOption("binary", count=4)), with notes under them for a running total. It can also name the run button (run_label="Make do-now"). fn is the plugin's code, not logic: it may read the files the inputs name (tables through core.csvio.read_table), must be quick, and is only a hint. A ChipChoice's value is the tuple of chips that are on, () for none. ResourcePicker(..., chip=True) draws the file as a chip like a code tool's. PanelSpec(joined=("topics",)) draws an input as part of the group above it (no hairline, a small label), and OptionsForm(..., inline=("count", "seed"), more=("spec", "title")) puts short fields on one row and folds what few runs need behind More. tools/quiz is the reference.

The panel's first CodeSource, ResourcePicker or (opening) FilePicker is its main file input, and the extensions it declares (filter_ext=".py", several as filter_ext=(".py", ".txt"), or the *.ext patterns in filters) put the tool on the Library's use bar for files of that type: a teacher previewing a .py sees a button that opens your tool with the file already picked. Nothing in the library needs changing (core.panel.main_file_input). A picker that takes any file (no filter_ext) is not offered there; hidden tools never are.

4. Wire run()

def run(self, inputs, ctx):
    source = inputs["marks"]
    if source is None:
        raise ToolError("Choose a marks file first.")
    table = read_table(source)  # identifier columns are already gone
    ctx.progress(0.3, "Analysing")
    analysis = logic.analyse(table, inputs["boundaries"], inputs["options"])
    folder = output_folder(inputs, ctx.config)
    # Aggregates only: per question and per grade. No row of the input is written.
    questions = write_table(analysis.questions, folder / f"{source.stem}_questions.csv")
    grades = write_table(analysis.distribution, folder / f"{source.stem}_distribution.csv")
    return RunResult(
        tables={
            "headline": stat_table(headline_stats(analysis)),
            "distribution": analysis.distribution,
            "questions": analysis.questions,
        },
        files=[questions, grades],
        summary=f"Analysed {len(analysis.questions)} questions from {len(table)} scripts",
        outcome=f"{len(analysis.questions)} questions · {len(table)} scripts",
    )

output_folder is the scaffold's: config.output_dir_for(None, "<tool>"), the output folder set in Settings, else <sessions root>/outputs/<tool>/; never the input's folder and never the current directory. Declare the same function on the panel as PanelSpec(writes_to=WritesTo(output_folder)) so the line under the run button and the Open output folder chip name it, and in a CLI resolve it the same way (the scaffold's cli.py does, with --out for one run). ctx.output_dir is the shell's shared choice, which is the input's own folder when Settings names none: fine for a sheet made from a program, not for a summary of a marks file that may sit in a shared folder or in examples/. ctx.resources is the Resource Library (None under a bare CLI run): use ctx.resources.search("while") or ctx.resources.pick(".py") to let the teacher choose a file from the indexed resources instead of a raw file dialog, and ctx.resources.open_externally(path) to hand a file to its native app. Never read Office files yourself.

5. CLI

Keep cli.py thin: parse arguments, build an Options, call plugin.run, echo result.summary. The GUI and CLI must do the same thing.

6. Test, lint, commit

just test
just lint

The boundary tests fail if the tool imports Qt or if logic.py does I/O. The pre-commit hook refuses any CSV/XLSX without a # FAKE DATA first line. git status after a run should list only src/staffroom/tools/my_tool/: a file anywhere else (above all in examples/) means the tool wrote beside its input. tests/test_discovery.py lists the shipped tools by sidebar group; once you give yours a group, glyph and order, add it there.

Running pupil code

Never exec it in the app. Use core.runner.run_source(source, stdin=..., timeout=...) (or run_file), which runs it in a subprocess with a hard timeout, a temp working directory and a 1 MB cap on each of stdout and stderr (MAX_OUTPUT_BYTES; the rest is drained and discarded, the text ends with truncation_marker() and the outcome's stdout_truncated / stderr_truncated say so). It returns stdout, stderr, exit code and whether it timed out. tools/trace shows how to ship a helper script alongside the program with extra_files. Put core.runner.limits_note(timeout) in the panel description so the teacher sees the limits, and keep docs/guide/running-code.md true: it is a fence against accidents, not a sandbox.

Producing worksheets and keys

Build a core.export.Document, add blocks to doc.section() and to doc.section("Answer key", answers=True), then call export(doc, ctx.output_dir, stem, formats). That writes the sheet and a separate key in each format with the house header and footer. Show what was written with PagePreview(key="pages") and pages={"pages": pages_from(files, sheet_text=..., key_text=...)} in the result: the panel gets Sheet and Key tabs with the real PDF pages, page thumbnails, Open PDF and Print…; a run that wrote Word only shows a note over to_text of the sheet and key instead. Keep texts["preview"] = to_text(...) for the command line's --preview. tools/worksheet and tools/parsons are the references. The blocks are Heading, Paragraph, Code, Numbered, TableBlock, HintBox, BoxedBlocks (numbered cut-out cells with dashed cut lines, laid out in columns), AnswerGrid (empty boxes headed by position) and PageBreak. A new block goes in core/export/model.py with a rendering in each of pdf.py, docx.py and text.py, so every tool gets it.

Options that apply, in words

An options form shows one row per field, so give it what it needs to show only the rows that matter and to say them in the teacher's words:

OptionsForm(
    key="options",
    schema=Options,
    shown_when={"laws": shown_when("topic", "boolean_simplification")},
    choice_labels={"topic": TOPIC_TITLES, "formats": OUTPUT_FORMAT_LABELS},
    switches=("difficulty", "formats"),
)

shown_when(field, *values) hides a field unless another field of the form holds one of the values (and is itself shown). A hidden field keeps what the teacher set in it, so it comes back unchanged, but run() gets the field's default: nothing the teacher cannot see reaches the tool, the same as leaving the flag off on the command line. choice_labels names the values of a Literal or Enum field (a value with no label shows as itself; OUTPUT_FORMAT_LABELS covers the shared both / docx / pdf), and switches draws a choice as a segmented switch instead of a dropdown: keep those to two to four short labels. The command line keeps the values. All of it lives in panel.py; core.panel refuses a field name, a value or a loop that cannot work when the spec is built.

A whole input can hide the same way, when it only matters for one setting: PanelSpec(shown_when={"taught_log": shown_when("pick", "taught")}) shows quiz's taught-log picker only while Questions from is Taught 1, 3 and 6 weeks ago. on names a form field as "<form key>.<field>", or the key of a Choice. The shell hides the group with the hairline over it; while hidden, run() gets the input's empty value (no file, no text, the choice's default) and a required input is not asked for. Put such an input after the setting it follows, so ticking the box opens it below rather than pushing the form down.

Presets for an options form

OptionsForm(..., presets=(preset("Year 9", top_level_only=True), ...)) puts a Preset dropdown above the fields. A preset pins some fields of the schema (core.panel.preset(name, **values)); choosing it sets those and leaves the rest as the teacher had them, and editing a field afterwards moves the dropdown to whichever preset still matches, or to Custom. The same rule is core.panel.matching_preset(presets, options): call it in logic to print the preset's name on the output, so a sheet says which preset it came from even when the options were typed on the command line (trace does this). Keep the presets in logic.py next to the options dataclass; the panel just passes them on.

Presets for an editable table

EditableTable(..., presets=(("OCR J277", rows), ...), preset_action="save_preset") shows a preset dropdown with Apply and Save as preset (with compact=True, choosing a preset applies it and Save as preset sits in the group heading). Saving calls plugin.act("save_preset", {"name": str, "table": Table}, ctx); persist it in ctx.config.tool("<tool>") and build the presets tuple from config in panel_spec() so it is there next launch. tools/marks is the reference.

Row actions (tables with buttons)

Declare an ActionTable(key, label, actions=(Action("diff", "Diff"), ...), refresh_action="refresh") among the outputs and give the plugin an act(name, row, ctx) -> RunResult method. The shell calls act("refresh", {}, ctx) when the panel is shown and after every run or action, and act("diff", row, ctx) when a row is selected and the button pressed. Put the result in RunResult.texts for a TextView, or return the refreshed table under the same key. tools/classroom is the reference.

If the widget you need does not exist

Do not build it in the tool. Add a dataclass to core/panel.py, a widget to shell/widgets/, a rendering branch in shell/tool_panel.py (inputs) or shell/widgets/output_pane.py (outputs), and a test. Then use it from the spec.