Skip to content

Declaring inputs ​

Writing an rta plugin is the fifteen-minute loop. This page is what a capability takes: its inputs, their types, how each surface asks for them, and how a plugin that talks to a service declares the connection once.

Declaring inputs ​

go
Inputs: []plugin.Field{
	{Name: "city", Type: plugin.String, Positional: true, Required: true, Help: "which city"},
	{Name: "units", Type: plugin.String, Default: "c", Options: []string{"c", "f"}},
	{Name: "key", Type: plugin.Secret, Help: "API key"},
	{Name: "out", Type: plugin.Path, Local: true, Help: "write the report here"},
}

Type is mandatory and closed — there is no inference from the name, because Secret is what makes a value masked and Path is what makes it completable, and neither is guessable. The host holds every value to it before your handler runs: one your accessor could not read — text where a Bool is declared, a boolean where a String is, a block where a list is — is refused on every surface, a value from the operator's config or a profile included, and never handed to you as the zero. Nothing is coerced: "true" is not read as true, because the next question is yes, and the one after that decides whether a connection is encrypted.

Your handler reads each input by name, as the type it declared: req.String("city"), req.Int("limit"), req.Float("ratio"), req.Bool("shout"), req.StringSlice("tag"), req.Duration("timeout"). An input nobody gave and nothing filled reads as the zero of its type, which is why Required and Default are declarations and not something to check for in the handler.

A credential a caller may give more than once is SecretSlice, not StringSlice. It is Secret's masking with StringSlice's shape, and declaring the list type alone is the mistake worth naming: the value is then written to the completion shortlist and, over MCP, into the record in cleartext, because every sink that hides a credential asks whether the type is one. vault.kv.set --data 'password=…' is the shape.

A length of time is Duration, not an Int called timeout: {Name: "timeout", Type: plugin.Duration, Default: "30s", Min: "1s", Max: "5m"}. An Int never said whether it counted seconds or milliseconds, and the answer lived in a Help string, so --timeout 30 was half a minute in one capability and thirty milliseconds in another. A Duration is written with its unit on every surface — 30s, 5m, 2h, 1d, 1h30m; Go's units plus d and w — carried as that text, never as a number, and read with req.Duration("timeout"), which is a time.Duration. A bare number is refused with the spelling that is accepted, not read as seconds, since the host would be guessing for the plugin that meant something else. An agent's schema publishes it as a string with the grammar as a pattern and the range in its description, and a JSON number sent to it is told that it needs a unit. Default, Min and Max are written the same way, as text; the plugin fails to load when one is not a duration written that way, a time.Duration included, which every surface would print as nanoseconds. sdktest notes an Int named like a time (timeout, ttl, interval, …) and fails one whose Help never says its unit.

What each one buys you:

  • Options becomes a TUI picker, shell completion, and an enum in the MCP schema. Use it for a fixed set of text, on a String or a StringSlice: declared on any other type, the plugin fails to load, because a number or a switch already says what it takes — Min and Max, or being a Bool. The host holds every value to it before your handler runs: one naming none of them is refused, on every surface, including a value from the operator's config. On the CLI, in the TUI and in config, a value naming an option in another case arrives spelled the way you declared it; over MCP the published enum is held exactly, and the refusal says so. A Default has to be one of the options.
  • Min and Max bound an Int, a Float or a Duration (written as text, Max: "5m"), and on an Int they are whole numbers: a fractional one fails the load, since an Int reads no fraction, its bounds included. A value the caller sends outside them is refused with the range named, never moved inside it. A number from the operator's config or a profile is held inside them instead, because one config key serves every capability that declares it and each may bound it differently. Either way your handler can take the bound as given, and a value that is not a number at all is refused, as every value of the wrong type is.
  • Suggest is a function, func(ctx context.Context, req plugin.Request) []string, returning what exists right now — your tags, their hostnames. An entry may carry a description after a tab, "alpha\tin north", which shell completion shows and the other surfaces drop. It runs on human surfaces only, never for an agent: the list itself is information. It must be cheap and silent on failure, because it fires on a keystroke — no network call, no prompt, no connection opened. It receives what the caller has supplied so far, on the CLI and in a TUI form alike, so a suggestion can depend on a sibling field being typed above it. Tab completes it on both surfaces. Not accepted on a Secret, a SecretSlice or a Text input: the list renders in plain text beside the box, which defeats a mask, and a body is written in $EDITOR rather than completed.
  • Live: true, on a field that declares a Suggest, says the list has to ask the service — a bucket's keys, a mount table — rather than read something local, and changes when it is asked. It is never called per keystroke and never by shell completion: only when a person presses Tab in a TUI form on a box with nothing left to accept, in your own process, within three seconds, and with the values the call would run with, credentials included and already resolved — the one case where a Suggest sees a Secret, since the same process receives them a moment later when the call runs. What has been typed in the box is in the request under the field's own name, so req.String("prefix") is what to list under, and an entry ending in a separator ("backups/") composes: accepting it stops the box extending, and the next press asks one level deeper. It stays read-only and silent on failure. Registration refuses Live without a Suggest, beside Options, or on anything but a String. It is a different switch from Live on a capability, which re-runs a Read view while it is on screen.
  • Local: true means the value names something on this machine, so it is refused over MCP. --out is the example: a grant authorises revealing a value, not choosing where on the operator's disk it lands.
  • Required means your handler never runs without the input. The host refuses a call that has no value for it once every layer has had its say — the caller, a profile, the operator's config — on every surface, before your handler, as core.input.missing. Not your Default: one beside Required fills the input before the host looks, so it is never missing, and the plugin fails to load — declare one or the other. An empty one, "" or an empty list, fails the same way: it fills nothing, and the tool schema would publish it as the value an input on its own required list gets when it is left out. Left out, empty text and an empty list all count as no value, because your accessor reads each of them exactly as it reads an input nobody gave. The refusal names the input the way the caller types it: --host or <table> at a terminal, the argument "table" to an agent, the box in a form. So a required input with a Config key is satisfied by the file, and a required one that is also Local by the operator alone. If an empty value means something to your handler, leave the input optional and decide there.
  • Positional makes the input an argument on the command line rather than a flag. Arguments are taken in the order the usage line shows them: the required ones first, in the order you declare them, then the optional ones — rta git blame <file> [path] whichever of the two is declared first. A list argument takes every argument after it, so it has to be the last in that order, and the plugin fails to load otherwise: make the other a flag, or the list the last argument.
  • Short is the one-letter form of the flag at a terminal — Short: "n" makes --limit also -n. Declare it for the few inputs a person types every day and leave the rest long. It is one ASCII letter, never on a positional input, never one the host owns (h, o and y: --help, --output, --yes) and never one of the capability's own twice; the plugin fails to load otherwise. Every other surface ignores it.
  • Piped is for rta's built-ins: sdk.Serve refuses a plugin declaring it before it serves, and the host refuses one when it loads it. It marks an input rta's own CLI reads from standard input when the call leaves it out — the token codec jwt decodes — and which every other surface therefore requires. Your plugin runs in a process of its own that never sees that pipe, so the marker would be a promise nothing keeps. Declare the input Required, or leave it optional and refuse an empty value in your handler.
  • Remote: true marks an input that points the call at another machine — the server a queue is parked on. A TUI form opened by acting on one of this machine's own records omits it, and every input declared With it (PassphraseField.OnlyWith("server")), since a box for a remote server on the screen answering the local queue is a box for a different call. rta explain says "only with --server" on such an input.
  • Path inputs are confined to the server's roots on an agent's call, whether the agent sent the path or it came from your Default or the operator's config, and your handler receives the path as the host resolved it: the path as it was when judged, which your handler opens by name in its own process, so the path gate says what that leaves to a caller who can write inside a root. A Local one arrives as typed, so a leading ~ is yours to resolve: plugin.ExpandHome does it the way the host does, and only for a leading ~ alone or before a / — ~alice and a file called ~notes are left alone.
  • Config names a key in the operator's configuration this input may be filled from when nobody passed it, so a connection is stated once instead of retyped. Precedence is caller, then config, then Default, and your handler cannot tell which it got.
go
{Name: "host", Type: plugin.String, Required: true, Config: "host"},
{Name: "port", Type: plugin.Int, Default: 5432, Config: "port"},
{Name: "mode", Type: plugin.String, Default: "prefer", Config: "tls.mode"},

The operator writes it under your plugin's own section, which is pinned to the binary they installed rather than to the namespace you declare — anything on $PATH can claim a namespace, and their stated values must not go to whoever won that race:

yaml
plugins:
  weather@1a2b3c4d5e6f:
    host: db.internal

rta doctor prints the exact line, digest included, and says so again if you upgrade and the pin goes stale. Config is refused on a Secret or SecretSlice input — configuration is a plaintext file read on every invocation, and a Secret's default is published in your MCP tool schema. Use Local: true and let the host resolve it from its own environment.

Declare it generously. Anything a person would set once and keep — the scope a call works in (a cluster, a namespace, a bucket, a collection), a limit, a depth, a parallelism, a TTL convention, a storage class, a dump format — wants a key, because every one of them is a thing an operator otherwise retypes on every call and gets wrong on one. The caller always wins, so a configured value is a default, never a lock. Three kinds of input do not get one: a selector of one record (a key, a path, a container, a backup name — there is no sensible default), a cursor (--after, --offset), and a destructive switch (--force, --overwrite, --replace, --clean): a default that skips a safety check, in a file nobody is watching, is exactly the footgun the check exists to prevent.

The scope is a flag, the record is the argument. A config-backed input cannot be positional — arguments bind left to right, so a config-filled first argument would change what a typed one means — which is why --cluster, --namespace and --bucket are flags with keys while the pod, the object and the backup stay positional. kubectl draws the same line with -n.

A connection a profile can fill ​

A plugin that talks to a service has a connection — where it is, and what authenticates to it — and an operator states one once, in a profile, instead of retyping it on every call. There is nothing profile-specific for you to write: a profile fills inputs you already declared, by rules rta applies to the declaration, and rta explain <capability> shows the config key each fillable input answers to.

  • A Config key makes an input fillable. A profile's set: block for your plugin uses the same keys as the plugins: section above, so set: {region: north} fills the input declared Config: "region", and the caller's own value still wins. A set: key no input declares is refused by rta profile set as one nothing reads, and rta explain <capability> lists the keys that exist.
  • A credential is a Secret declared Local: true, EnvFallback: true. Config is refused on a Secret, so the operator maps it instead: secrets: {token: kv:staging-token} in the profile is a reference rta resolves at call time from the store (or kube: for a cluster's Secret) and hands you as the input's value, never written to a file. The default connection of a profile also reads RTA_PROFILE_<PROFILE>_<INPUT> for any input it can fill; a labeled instance reads only its set: and secrets:. You see a value, and cannot tell which source it came from.
  • Never a Path, and never the input a grant is scoped by — your Scope and ScopeAlso inputs. A profile chooses where a call goes, not which file it reads or writes, and not the record a grant was checked against.
  • A service reached over the network can have a forward stand in for its address. A profile may state kube: or ssh: for a connection, and rta opens a kubectl port-forward or an ssh -L for the call, hands your plugin a local address, and closes it afterwards; you never learn a tunnel was there. To receive that address, say which inputs hold it: Endpoint: plugin.EndpointHost together with plugin.EndpointPort (an Int), or plugin.EndpointAddress for one input holding host:port, or plugin.EndpointURL for one holding a URL. Each such input has to be Local — an address an agent could choose is a credential carried wherever it likes — and has a Config key, at most one input takes each role, and registration refuses a plugin that declares half an address. A plugin with no endpoint input cannot be given a forward, and a profile that states one for it is refused rather than opened and ignored. The same declaration is what gives a capability's command the flags that state a connection for one call — --kube, --secret and --secrets-from — so a plugin does nothing to offer them, a capability with no endpoint input does not have them, and they are on no tool an agent is offered: an input of your own may not take those names, since the host's flag would shadow it.
  • A forward is a raw byte pipe, so TLS over it is the host's question. Mark the input that holds your TLS switch plugin.EndpointTLS: a Bool, or a String whose Options include one of disable, off, false, none or insecure, which is what the host fills in over a forward. A CA file, a client certificate or its key — anything that only matters once TLS is negotiated — is declared TLSAdjacent: true, and is refused beside a forward instead of silently doing nothing. Profiles is what the operator reads about this, and what to name in a hint.

A profile can only name a plugin that is installed — rta profile set refuses one that is not registered — so rta plugin dev does not apply one. To try one on the plugin you are writing, build it onto $PATH, approve it with rta plugin trust <name>, and state the profile with rta profile set dev --plugin <name> --set region=north; rta <name> <capability> --profile dev then runs through it, and rta profile show dev says where each value came from.

Next ​

Answers, errors and hints — what a handler returns beyond the basics: hints in the reader's own words, warnings, and the one error that is a refusal.