Authoring device profiles¶
A device profile is a JSON file that tells termapy what commands a serial device understands and how to interpret its responses. The MCP bridge consumes profiles to give LLMs structured device control: typed JSON in, typed JSON out, with safety gating.
This page is for anyone authoring a profile — engineers writing one by hand, and LLMs drafting one from a help dump. It's the single source of truth for the schema, the safety taxonomy, and the rules.
Note: AI such as Claude Code can do a pretty good job of sending commands interactively without a profile at all, just looking at any help text or sample responses that you give it. It can work with fairly well with a small amount of trial and error. However, if you are going to use AI to interact with your device on an ongoing basis it is worth it to invest in creating a profile which reduces friction and gives you more robust control and better error handling because the LLM can understand the commands and the types.
IF you have control over the device firmware, build a command that returns the json profile directly from the device.
This way users of your device can be up and running with AI control in a few minutes.
File layout¶
A profile is a JSON object with these top-level keys:
{
"profile_version": 2,
"profile_revision": "1.0.0",
"profile_date": "2026-05-03",
"device": {"name": "...", "vendor": "...", "model": "..."},
"error_detection": {"pattern": "^ERROR(?::\\s*(?P<message>.+))?$"},
"types": {"on_off": {"kind": "enum", "values": ["on", "off"]}},
"commands": {"AT": {...}, "AT+TEMP": {...}, ...}
}
Only commands is strictly required. Everything else is optional.
Wire-level settings live in cfg, not the profile. Baud rate,
byte size, parity, stop bits, flow control, encoding, and line
ending are session properties — they depend on the user's USB
adapter and hardware setup, not on the device contract. Set them
in your termapy_cfg/<name>/<name>.cfg file. See help config
for the field reference. For NDJSON devices, set cfg.protocol
to "ndjson"; the default "text" covers most devices.
Per-command schema¶
Each entry in commands describes one device command:
"AT+TEMP": {
"enabled": true,
"help": "Read temperature",
"args": "",
"long_help": "Returns the on-board sensor reading...",
"safety": "readonly",
"response": {
"format": "regex",
"pattern": "(?P<celsius>-?\\d+\\.\\d+)C",
"types": {"celsius": "float"},
"timeout_ms": 200
}
}
enabled (boolean, default true)¶
The kill switch. false means "this entry exists in the profile but
is hidden from the MCP catalog and not dispatched by the executor."
A bare invocation of a disabled command falls through to literal
/term.send (the same behavior you get without any profile at all).
When drafting a profile for a legacy device — for example after
pasting a device's help output into Claude — every entry MUST start
with enabled: false. The engineer reviews each command and flips
the flag to true only after auditing safety, args, and (ideally)
testing one or two sample responses. The default of true exists
to keep curated profiles working unchanged; it is not the default
you should emit when generating a draft.
help (string, required)¶
One-line description. Shown by /help <cmd>. Copy verbatim from
the device's own help table when authoring from a help dump.
args (string, optional)¶
Argument syntax for human display. Conventions:
<required>, {optional}, a|b|c for enums.
long_help (string, optional)¶
Multi-line prose for /help <cmd>. Useful for commands with subtle
semantics, examples, or warnings.
safety (string, default "safe")¶
Four tiers, in order of increasing caution:
| Tier | Meaning | Examples |
|---|---|---|
readonly |
Pure observation; no state change | bat, temp_c, version, info |
safe |
Default; no enduring effect | help, clr |
mutable |
Changes device state but reversible | set_led on, baud 9600 |
destructive |
Irreversible / data-loss / requires confirmation | reset, factory_clear, erase |
Only destructive triggers the MCP confirmation gate. The
LLM cannot run a destructive command without explicit confirm=true
on the tool call (which a well-behaved client elicits from the user).
The other tiers are documentation that helps the LLM and the
engineer reason about the device.
When in doubt between mutable and destructive: err on the side
of destructive. Friction is recoverable; data loss isn't.
response (object, optional)¶
Describes how to parse the device's reply. Five formats:
format |
What it does | Returned value shape |
|---|---|---|
none |
Verify silence: wait briefly (default 100 ms), fail if the device replies | {"sent": true, "cmd": "..."} |
literal |
Reply must equal pattern exactly |
the string (or fail) |
regex |
re.search(pattern, response) — named groups + types coerce to dict |
{"celsius": 23.4, ...} |
lines |
Collect lines until terminator regex matches or idle gap |
["line1", "line2", ...] |
json |
Parse the full response as one JSON document | the parsed value |
Common patterns:
- Single value with units:
regexwith(?P<value>\d+\.\d+)V,types: {value: float}. - Multi-line dump ending with
OK:lineswithterminator: "^OK$". - Multi-line with no terminator:
lines(idle-gap collection). - NDJSON device:
json. - Side-effect command, no reply:
none. The executor waits a short window (default 100 ms, override withtimeout_ms) and fails the call if the device replies anyway — catching stale profiles and firmware regressions instead of silently dropping the unexpected bytes. Whitespace-only replies are ignored. Settimeout_ms: 0to opt out of the silence check entirely (true fire-and-forget).
response.timeout_ms (integer)¶
How long to wait for the response before timing out. Defaults to
cfg.default_response_timeout_ms (which itself defaults to 1000
ms). Bump for slow operations like resets, self-tests, or flash
erases.
response.types (object, optional)¶
Maps regex named-group names to type coercions. Recognized type
strings: int, float, bool, hex (parses base-16), str
(default). Failed coercions silently fall back to the raw string —
the LLM gets something even on bad data.
send_template (string, optional)¶
For commands that take a single inline argument. Use Python-format
syntax: "AT+LED {state}" (note the space — the wire syntax matches
the device, not necessarily termapy convention). The LLM types
AT+LED on; termapy matches against the template, sends it through.
If omitted, the command is sent verbatim.
typed_args (array, optional)¶
Structured argument schema used by codegen tools. Each entry:
{"name": "...", "type": "<builtin or custom name>", "required": true,
"help": "...", "enum": [...], "min": ..., "max": ...}.
type accepts either a builtin (int, float, bool, hex,
str) or a custom name declared in the profile's top-level types
block (see below). When the MCP dispatcher binds a typed_args
entry, the validator runs before the request hits the wire — bad
values short-circuit to a structured failure naming the rejected
value, the violated constraint, and the canonical command name.
Profile-local types¶
A v2 profile may declare a top-level types block — a map of named
user-defined types referenced by typed_args[i].type. This is how a
device declares its own argument vocabulary (e.g. one device's
lenient bool of on/off/true/false/yes/no/1/0/high/low vs. another's
strict 0/1) without forking the schema.
The five builtins always resolve directly; custom names cannot
shadow them (bool, int, etc. are reserved). Six kind values
are recognized:
kind |
Required fields | Behavior |
|---|---|---|
enum |
values (array) |
Exact-match against the list; values stringified for compare |
int_range |
min, max |
Coerce to int; check min ≤ v ≤ max |
float_range |
min, max |
Coerce to float; same bounds check |
str_length |
min_len and/or max_len |
Coerce to str; check length against bounds |
pattern |
regex |
re.fullmatch(regex, value) — anchored both ends |
format_spec |
spec |
Parsed via the protocol format-spec language; validator is a pass-through stub today |
Example:
{
"types": {
"on_off": {"kind": "enum", "values": ["on", "off", "true", "false", "yes", "no", "1", "0"]},
"baud": {"kind": "enum", "values": [9600, 19200, 38400, 57600, 115200]},
"percent": {"kind": "int_range", "min": 0, "max": 100},
"voltage": {"kind": "float_range", "min": 0.0, "max": 5.0},
"nickname": {"kind": "str_length", "min_len": 1, "max_len": 16},
"duration": {"kind": "pattern", "regex": "^\\d+(us|ms|s)$"},
"byte": {"kind": "format_spec", "spec": "Val:H1"}
},
"commands": {
"ECHO": {"help": "Toggle echo.", "typed_args": [{"name": "state", "type": "on_off"}]},
"SETBAUD": {"help": "Change baud.", "typed_args": [{"name": "rate", "type": "baud"}]},
"SETDUTY": {"help": "Duty cycle.", "typed_args": [{"name": "pct", "type": "percent"}]}
}
}
Notes:
format_specis a wired-up stub. The schema accepts it and the registry parses thespecstring via the same format-spec parser used by/proto.*(see the protocol-testing guide). The validator is currently a pass-through — calls succeed without checking individual bytes — so authors can declare binary-field types today and the byte-level enforcement lands when needed.- Case is significant. Enum members match exactly. If a device
accepts both
ONandon, list both explicitly. - No
typesblock ⇒ no behavior change. Existing profiles using only builtintyped_args.typevalues keep working identically. - The catalog emits the
typesblock verbatim and inlines atype_infofield on eachtyped_argsentry so an LLM reading the catalog sees the full contract per arg without cross-referencing.
Top-level blocks¶
error_detection¶
Server-side error pattern, applied across all responses. When the
device returns text matching pattern, the executor fails the call
with the captured message group as the error string. Wins over
response.pattern if both could match.
device¶
Documentation only: name, vendor, model, optional prompt
string, optional startup_banner regex.
Authoring rules for LLMs drafting from a help dump¶
When a user pastes a device help table and asks for a profile draft:
-
Every entry gets
enabled: false. No exceptions. The user reviews and flips them on one at a time. -
Copy
helpandargsverbatim from the help table. -
Classify
safetyfrom the description. Use the table above. When uncertain, prefer the more conservative tier and add a note to the top-level_notesblock (see below). -
Default
response.format: "lines"with no terminator andtimeout_ms: 1000. This produces a list-of-strings result that's safe and useful even without sample responses. -
Bump
response.timeout_msfor commands whose names or help text suggest slow operations:reset,mfg,standby, anything involvingflash,wait,erase,cal. -
Don't invent
response.patternorresponse.typeswithout sample responses. A wrong regex is worse thanformat: lines. If the user provides sample responses, then upgrade toregex. ("response.types" here means the per-group coercion map inside aresponseblock, NOT the top-leveltypesblock — see rule 10.) -
Mark hazardous commands even when not strictly destructive. GPIO writes, motor controls, RF transmits, fee-based API calls — set
safety: destructiveso the gate fires and add a note. -
Add a top-level
_notesblock summarizing what was inferred and what needs human review:
"_notes": {
"drafted_from": "help output pasted by user",
"needs_review": [
"mfg — could be destructive (factory programming?); please confirm",
"baud — changes wire baud rate; bridge may need reconnect handling",
"gpio — drives outputs; consider physical safety implications",
"repeat — recursive, response shape depends on inner command"
]
}
Underscore-prefixed keys are accepted by the schema as metadata.
-
Never set
enabled: truein a draft, even for commands that look obviously safe likeversion. The user toggles each one themselves — that's the audit signature. -
Promote repeated argument vocabularies into the top-level
typesblock. When you see the same vocabulary repeated across commands — every toggle accepting on/off, several commands sharing a["1","2","tot"]enum, multiple args matching<digits>(us|ms|s)— declare the contract once at the top level and reference it by name from eachtyped_argsentry. This gives the LLM a precise, reusable vocabulary instead of forcing it to read every command's inline enum.Only declare values you can see in the help output — don't extend the vocabulary speculatively. Pick from the six kinds (
enum,int_range,float_range,str_length,pattern,format_spec; see the "Profile-local types" section above). Custom type names must NOT collide with the five builtins (int,float,bool,hex,str); pick a domain name likeon_off,channel,duration.Skip dual-mode args — commands whose help text says "accepts a bool OR a duration" must stay as builtin
str(orbool) until a union kind exists, or the validator will reject the second mode.
Drafting a profile with an AI¶
Two flows, depending on whether you have termapy MCP available:
With MCP (recommended)¶
The bundled draft_profile MCP prompt embeds this entire guide and
adds critical drafting rules. In Claude Desktop / VS Code MCP:
- Invoke the
draft_profileprompt (the client UI lists available prompts; pick this one). - Paste your device help dump in the
help_outputfield. Optionally add a startup banner, sample responses for a few high-value commands, and any engineer notes ("RF transmits are billable, mark destructive"). - The LLM reads this guide + your inputs and returns one JSON object
with
enabled: falseon every command. - Save the draft to
<cfg>/<device>.profile.jsonand proceed to the engineer workflow below.
Without MCP (chat-only)¶
Plain Claude / ChatGPT works fine. Paste the prompt template below,
then your inputs. Edit the device_name line and any (none)
placeholders.
You are drafting a v2 device profile for termapy from artifacts I'm
pasting below. Follow the embedded authoring guide precisely.
Critical rules (read the guide for full context):
- Every command entry MUST have `enabled: false`. No exceptions.
- When unsure about safety, prefer `destructive`. Friction is
recoverable; data loss isn't.
- Default `response.format: "lines"` with `timeout_ms: 1000` unless
sample responses justify a stricter format.
- Bump `timeout_ms` for slow commands (reset, mfg, flash, cal).
- Promote repeated argument vocabularies into the top-level `types`
block. Reference custom types by name from `typed_args[i].type`.
- Don't invent commands, regex patterns, or type values you can't
see in the artifacts.
- Add a top-level `_notes` block summarizing what was inferred and
flagging commands you couldn't classify with confidence.
Return ONE JSON object matching the v2 profile schema, wrapped in a
```json ... ``` code block. After the code block, summarize: how
many commands, how many you flagged as needing review, and any
commands you couldn't classify.
# Authoring guide
<paste the contents of authoring-profiles.md here>
# Inputs
## Device name
<device_name>
## Help output
```text
<paste the device's `help` / `?` / `AT+HELP` output here>
```
## Startup banner
```text
<paste the banner the device prints at boot, or write "(none)">
```
## Sample responses
```text
<paste a few `cmd -> response` pairs for high-value commands, or "(none)">
```
## Engineer notes
<free-form: anything the LLM should know that isn't in the artifacts,
e.g. "the RF transmit command is billable", "this firmware is
SB_0_8 dated 2026-05-01">
Drafting from device source code¶
If you have the firmware source (C, Rust, Python — anything), point the LLM at it instead of (or in addition to) a help dump. Useful extras to ask the LLM to extract:
- Command dispatch table for the canonical command list (look for
arrays of
{name, handler}structs, sub-command routers, or a centralif/else if (strcmp(arg, "x") == 0)ladder). - Argument parsers for vocabulary —
parseBool,parseChannel, enum string tables. These define the exacttypesblock entries. printf/putscalls in command handlers for response shape — use these to draftresponse.regexpatterns with confidence.- Constants for timing —
#define WDT_RESET_TIME_MS 5000tells you a preciseresponse.timeout_msfor the reset command.
Source-code drafts are typically more precise than help-dump drafts (you can see the actual parsers, not just the help string), but they're also longer to produce — the LLM has more to read. For a device you already have a help dump from, prefer the help-dump flow; fall back to source when the help text is terse or ambiguous.
Workflow for the engineer¶
- Get a help dump from the device.
- (Optional) Capture sample responses for high-value commands.
- Open Claude (or another LLM) in chat.
- Paste the help dump and ask for a v2 profile draft. The LLM
reads this guide (embedded in the
draft_profileMCP prompt) and produces a draft withenabled: falseeverywhere. - Save the draft as
<cfg>/<device>.profile.json. - Run
/profile.load <device>.profile.jsonin termapy. - Review each command. Set
enabled: trueon the ones you've audited. Reload (/profile.loadagain) to pick up changes. - Test against the real device through MCP.
- As you discover response shapes, upgrade entries from
format: linesto typedregex.
The profile is just a JSON file; edit it freely.
See also¶
/profile.validate <path>— schema check against this guide./profile.load <path>— install a profile into the active session./profile.info— inspect the active profile./mcp.info— see destructive count, enabled-vs-draft split./profile.load cmd=<command>— for devices that publish their own profile via a wire command likeAT+HELP.JSON. Same destination as the file path -- the active profile namespace -- so the rest of the system is agnostic to where the JSON came from.