Skip to content
Kward

Plugins

Plugins are trusted local Ruby extensions for Kward. Use them when you need behavior that prompts, skills, or instructions cannot provide.

Good plugin use cases:

  • add a slash command for a personal workflow,
  • expose a local integration as a model-callable tool,
  • show project/session status in the terminal footer,
  • add concise local context to prompts,
  • log or observe transcript events,
  • expose local commands to an RPC client.

Plugins run inside the Kward process with your user permissions. Install only plugins you trust.

When to use a plugin

Need Better choice
Reusable prompt text prompt template
Reusable model instructions skill
Repository rules AGENTS.md
Local Ruby code or integration plugin

See Extensibility for the full overview of Kward's extension points and prompt assembly order. See Transports for external messaging and event integrations.

Where plugins live

Kward loads trusted Ruby plugin entrypoints from:

~/.kward/plugins/*.rb
~/.kward/plugins/*/plugin.rb

For a larger plugin, use a directory with plugin.rb as its entry point. Kward loads only that file automatically, so use require_relative there to load any sibling files.

Plugins are not loaded from the current workspace or a custom KWARD_CONFIG_PATH directory. This prevents a project checkout from silently adding executable Ruby code to Kward.

A first plugin

Create the plugin directory:

mkdir -p ~/.kward/plugins

Create ~/.kward/plugins/hello.rb:

Kward.plugin(id: "com.example.hello", version: "1.0.0", api: 1) do |plugin|
  plugin.command "hello", description: "Say hello", argument_hint: "[name]" do |args, ctx|
    name = args.strip.empty? ? "there" : args.strip
    ctx.say("Hello, #{name}.")
  end
end

Start Kward and run:

/hello World

When developing plugins or prompt templates, use /reload inside Kward to reload configured prompt files and all plugin files without restarting. This picks up prompt edits, changes to existing plugins, and new plugin registrations, then refreshes slash-command completion and rebuilds the system message.

Plugin identity and host services

Give a reusable plugin a stable reverse-domain-style id, its own version, and the Kward plugin API it targets. API version 1 is currently supported. Kward skips plugins that declare an unsupported API version or duplicate another plugin's ID.

An identified plugin receives one shared host for configuration, private durable storage, secret lookup, and logging:

Kward.plugin(id: "com.example.issues", version: "1.2.0", api: 1) do |plugin|
  host = plugin.host

  plugin.command "issue-server", description: "Show the issue server" do |_args, ctx|
    visits = host.storage.get("visits").to_i + 1
    endpoint = host.config.fetch("endpoint")
    host.storage.put("visits", visits)
    ctx.say("#{endpoint} (visit #{visits})")
  end
end

Configure the plugin under its stable ID in config.json:

{
  "plugins": {
    "com.example.issues": {
      "endpoint": "https://issues.example.com"
    }
  }
}

The host exposes:

  • host.id, host.version, and host.api_version;
  • host.config, an immutable copy of the plugin's namespaced configuration;
  • host.storage.get, put, and delete for JSON-compatible values;
  • host.secret(name, env: nil) for private config or environment lookup;
  • host.logger, a standard Ruby logger routed through Kward's diagnostic output.

Storage is kept in plugin_state/<plugin-id>/state.json under Kward's active config directory and survives /reload. Secret lookup checks plugin config, then the optional explicit environment variable, then a conventional name such as KWARD_PLUGIN_COM_EXAMPLE_ISSUES_TOKEN. Do not log secret values.

Existing Kward.plugin do ... end files remain supported, but plugin.host is nil until the plugin declares stable identity metadata.

Lifecycle and owned background work

Plugin files are loaded without starting runtime work. Identified plugins can register lifecycle callbacks for the points where managed work is safe:

Kward.plugin(id: "com.example.watcher", version: "1.0.0", api: 1) do |plugin|
  plugin.on_start do |host|
    subscription = Watcher.subscribe { |event| host.logger.info(event) }
    host.on_cleanup(subscription) { |value| Watcher.unsubscribe(value) }

    host.background(name: "poller") do |cancellation|
      until cancellation.cancelled?
        Watcher.poll
        sleep 1
      end
    end
  end

  plugin.on_reload { |host| host.logger.info("Reloading") }
  plugin.on_shutdown { |host| host.logger.info("Stopping") }
end

Lifecycle behavior is explicit:

  • on_start runs after loading, when the registry becomes active. A newly loaded registry receives on_start after /reload too.
  • on_reload runs on the old plugin instance immediately before Kward cleans up its resources.
  • on_shutdown runs immediately before final process cleanup.
  • Callback failures are reported as warnings and do not prevent other plugins from cleaning up.

host.background returns a PluginTask. Its block may accept a cooperative Cancellation token. Kward cancels and waits up to a bounded deadline for owned tasks during reload or shutdown; blocking work should register cleanup that closes the socket, stream, or other resource needed to wake it. Tasks also accept an optional parent token with cancellation:.

host.on_cleanup(resource) { |resource| ... } returns an idempotent PluginDisposable. Call dispose to unsubscribe early, or leave it registered for automatic cleanup. host.manage is an alias. Cleanup runs in reverse registration order so dependent resources unwind predictably.

Plugin-owned tab hosts expose the same background, on_cleanup, and manage methods. Their resources are cleaned up when a local tab closes or its shared plugin-chat runtime shuts down. A tab driver may implement close (or shutdown as a fallback) for its own final cleanup; Kward invokes it before closing the tab host.

Do not open network connections or start threads directly while the plugin file is loading. Register them from on_start, a command, or a plugin-tab factory so Kward can own their lifetime.

Add a slash command

Use plugin commands for local actions that should not call the model.

Kward.plugin do |plugin|
  plugin.command "session-info", description: "Show session details" do |_args, ctx|
    ctx.say("Session: #{ctx.session_name || ctx.session_id || 'unnamed'}")
    ctx.say("Workspace: #{ctx.workspace_root}")
  end
end

Command names do not include /. They must start with a letter or number and may contain letters, numbers, _, and -.

A plugin command cannot replace a built-in command or prompt-template command.

Typed command arguments and results

Add an object JSON Schema when a command needs validated arguments. Interactive slash commands use shell-style --flags; RPC clients may send either the same text or an argument object. Declare positionals: when selected properties may be supplied without flags:

Kward.plugin(id: "com.example.release", version: "1.0.0", api: 1) do |plugin|
  plugin.command "deploy",
    description: "Deploy a service",
    argument_hint: "SERVICE [--environment NAME] [--dry-run]",
    schema: {
      type: "object",
      properties: {
        service: { type: "string" },
        environment: {
          type: "string",
          enum: %w[staging production],
          default: "staging"
        },
        dry_run: { type: "boolean", default: false },
        labels: { type: "array", items: { type: "string" } }
      },
      required: ["service"]
    },
    positionals: ["service"] do |args, ctx|
      deployment = Release.deploy(
        args.fetch("service"),
        environment: args.fetch("environment"),
        dry_run: args.fetch("dry_run"),
        labels: args.fetch("labels", [])
      )
      ctx.result(
        message: "Queued deployment #{deployment.id}.",
        data: { deployment_id: deployment.id }
      )
    end
end

For example:

/deploy api --environment production --dry-run --labels urgent --labels "release candidate"

Typed command handlers receive a string-keyed argument hash through both the first block argument and ctx.args. Supported property types are string, integer, number, boolean, array, and object; array items must be scalar. Use --flag or --no-flag for booleans, repeat an array option to collect values, and use -- before positional text that starts with a dash. Unknown, missing, duplicate, incorrectly typed, and out-of-enum arguments are rejected before plugin code runs. Commands without schema: keep receiving their raw argument string for compatibility.

ctx.result(message:, data:) returns optional user-facing text plus optional JSON-compatible machine data. The TUI displays message. RPC returns the full structured result, and asynchronous slash-command turns also emit a pluginCommandResult event before their normal answer event.

Add a namespaced action

Actions are typed operations intended for trusted RPC integrations rather than slash-command completion. They require identified plugin metadata and receive a stable <plugin-id>/<action-name> ID:

Kward.plugin(id: "com.example.release", version: "1.0.0", api: 1) do |plugin|
  plugin.action "status",
    description: "Read deployment status",
    schema: {
      type: "object",
      properties: { deployment_id: { type: "integer" } },
      required: ["deployment_id"]
    } do |args, ctx|
      deployment = Release.find(args.fetch("deployment_id"))
      ctx.result(data: { id: deployment.id, state: deployment.state })
    end
end

RPC clients discover actions with pluginActions/list and invoke them with pluginActions/run. Action arguments must be an object and results always use the structured { message, data } contract. Synchronous actions support ctx.say, progress, and notifications, but blocking UI requests fail closed so the RPC reader cannot deadlock. Actions are disabled when the session execution profile disables plugin commands. They are not exposed as local TUI commands; register a typed command as well when people should invoke the operation from the composer.

Add a model-callable tool

Plugin tools let the model call trusted local Ruby integrations without requiring an MCP server. Define a model-facing description and a strict object JSON Schema for the arguments:

Kward.plugin do |plugin|
  plugin.tool "issue_search",
    description: "Search the local issue tracker",
    schema: {
      type: "object",
      properties: {
        query: { type: "string", description: "Issue search text." },
        limit: { type: "integer", description: "Maximum results." }
      },
      required: ["query"]
    } do |args, ctx|
      ctx.cancellation&.raise_if_cancelled!
      IssueTracker.search(args.fetch("query"), limit: args.fetch("limit", 10))
        .map { |issue| "#{issue.id}: #{issue.title}" }
        .join("\n")
    end
end

The handler receives a parsed argument hash and a normal plugin context. It should return model-facing text. ctx.cancellation contains the active cooperative cancellation token, and ctx.cancelled? is a convenient boolean check for longer operations.

Plugin tool schemas are exposed in normal CLI, RPC, Pan, and transport-backed agent turns. Kward forces additionalProperties: false, validates required property names, reports the tool source as plugin, and routes execution through the normal permission policy, approval bridge, lifecycle hooks, output compaction, and transcript artifact storage. Restricted execution profiles can filter plugin tools by name or remove all tools. Editor-scoped prompts, shell-agent prompts, and strict worktree agents do not receive plugin tools. Plugin-owned chats continue to own their own tool behavior.

Plugin tools cannot replace built-in, MCP, or other plugin tools. Duplicate plugin registrations are skipped with a warning. Because trusted plugin code can perform arbitrary local or network effects, an enabled permission policy treats plugin tools as approval-requiring operations unless an explicit allow rule matches the tool or source: plugin.

Use structured plugin UI

Plugin commands and model-callable plugin tools receive ctx.ui, a frontend-neutral interface for questions, choices, confirmation, text input, notifications, and progress:

plugin.command "release", description: "Prepare a release" do |_args, ctx|
  environment = ctx.ui.select(
    "Environment",
    [
      { label: "Staging", value: "staging", description: "Deploy for testing." },
      { label: "Production", value: "production", description: "Deploy publicly." }
    ]
  )
  next unless environment
  next unless ctx.ui.confirm("Release", "Deploy to #{environment}?")

  tag = ctx.ui.input("Release tag", "For example, v1.2.0")
  next if tag.to_s.empty?

  ctx.ui.progress(id: "release", message: "Preparing #{tag}", percent: 25)
  # Perform work here.
  ctx.ui.progress(id: "release", message: "Prepared #{tag}", percent: 100, done: true)
  ctx.ui.notify("#{tag} is ready for #{environment}.", level: :success)
end

Available methods:

  • ctx.ui.question(questions) uses the same validated 1-4 question contract as ask_user_question and returns its answer array or nil when cancelled;
  • ctx.ui.select(title, options, message: nil) accepts 1-100 strings or { label:, value:, description: } objects and returns the selected value;
  • ctx.ui.confirm(title, message = nil, default: false) returns a boolean;
  • ctx.ui.input(title, placeholder = nil, default: nil) returns text or nil;
  • ctx.ui.progress(id:, message:, percent: nil, done: false) publishes a non-blocking progress update;
  • ctx.ui.notify(message, level: :info) publishes an info, success, warning, or error notification;
  • ctx.ui.supported?(:select) and ctx.ui.capabilities let a plugin inspect the active frontend before requesting interaction.

Blocking requests fail closed when the active frontend does not support them: questions, selections, and input return nil, while confirmation returns false. Notifications and progress fall back to normal ctx.say text. Inputs and emitted text are bounded, and plugin-tool cancellation is checked before and after blocking requests.

The interactive terminal implements all six primitives. RPC implements them for plugin commands submitted through turns/start and for plugin tools; use that asynchronous turn path when a command needs to wait for UI input. Synchronous RPC commands/run supports notifications and progress but deliberately fails closed for blocking requests so the protocol reader cannot deadlock waiting for its own response. Pan has no interaction bridge, so blocking requests fail closed and non-blocking output falls back to its existing plugin message event. Transport gateways expose blocking requests as transport-neutral interactions; the transport adapter decides whether and how to render and answer them.

Request a model response from a command

Use ctx.request_turn(system: text) when a slash command should run the active session's model immediately with turn-scoped system instructions. For example, save this as ~/.kward/plugins/iddqd.rb and run /reload:

Kward.plugin(id: "com.example.iddqd", version: "1.0.0", api: 1) do |plugin|
  plugin.command "iddqd",
    description: "Run a prompt as system instructions",
    argument_hint: "<prompt>" do |text, ctx|
    if text.strip.empty?
      ctx.say("Usage: /iddqd <prompt>")
      next
    end

    ctx.request_turn(system: text)
  end
end

Then enter /iddqd Answer in French and explain the current design. The text needs no quoting or flag parsing. Kward streams the response just like an ordinary turn, with the same tools, permissions, hooks, cancellation, and tab ownership. This does not replace Kward's base system prompt or bypass host security policy.

The call stages a request and returns nil; the host starts the model only after the command successfully returns. A handler error or cancellation discards the request. Each command may stage one request, containing nonblank text of at most 65,536 bytes. Instructions last through tool continuations, prompt refreshes, compaction, and retries in that turn, then expire even if the turn fails.

Check ctx.turn_requests_supported? before offering this behavior on an unknown host. It is supported by interactive CLI session commands, asynchronous RPC turns/start commands, and session-backed transports whose execution profile allows plugin commands. Synchronous RPC commands/run, plugin actions, plugin owned chats, tools, hooks, status renderers, editor/shell prompts, and Pan do not provide this capability. Unsupported calls raise an error rather than silently launching work or submitting the text as a user prompt. Pan does not dispatch plugin slash commands; use the TUI or RPC instead.

History and provider notes: the invocation and instruction text are stored in the session for transcript display and export. Restoring, cloning, or forking the session does not reactivate the instructions. Later model requests omit these history entries; compaction receives an informational placeholder instead of the expired text. System-level input uses each provider's native instruction mechanism. On Codex (ChatGPT subscription), the command becomes a new developer message at its position in the conversation: that backend rejects inline system messages. Direct OpenAI Responses and chat-completions payloads use an ordered system message. These turn instructions are not folded into the base preamble, so the model receives a new instruction after the preceding dialogue.

Anthropic and Gemini instead collect system instructions into a separate system field; they cannot preserve that ordered-message boundary and require existing dialogue. A system-only turn in an empty session is rejected explicitly. Other provider/model restrictions surface as normal request errors. No synthetic user prompt is inserted.

For instructions that should remain active in future turns instead, use prompt context below.

Add prompt context

Prompt context is short text injected into future model requests.

Use it for stable facts the model should know, not for large files or secrets. The block should return a string (injected into the system prompt) or nil (skipped). The example below uses Ruby's next to return nil early when the condition does not match.

Kward.plugin do |plugin|
  plugin.prompt_context do |ctx|
    next unless File.exist?(File.join(ctx.workspace_root, "Gemfile"))

    "Workspace note: this project uses Ruby. Prefer bundle exec for project commands."
  end
end

If plugin state changes and Kward should rebuild the active system message, call:

ctx.refresh_system_message!

Identified plugins can contribute compact status without replacing status from other plugins. Give each contribution a stable name:

Kward.plugin(id: "com.example.session-status", version: "1.0.0", api: 1) do |plugin|
  plugin.status "session", order: 10, priority: :high do |ctx|
    {
      text: ctx.session_name || "unnamed",
      tooltip: "Current Kward session"
    }
  end

  plugin.status "messages", order: 20, priority: :low do |ctx|
    "#{ctx.transcript.messages.length} messages"
  end
end

Kward joins visible contributions with ·. Lower order values appear first. priority may be :low, :normal (the default), or :high. When the terminal is too narrow, Kward removes complete low-priority contributions first, followed by normal- and high-priority contributions. Among contributions with the same priority, later ones are removed first.

Return a string for ordinary status, a hash with text and optional tooltip for structured clients, or nil to hide the contribution temporarily. One renderer failing does not hide status from other plugins. Kward evaluates the contributions at most once per second and reuses their values between refreshes.

RPC clients receive both the combined text fallback and the individual structured segments. Terminal footers display the combined text; tooltips are available to clients that can render them.

The older plugin.footer API remains supported. Each legacy footer is treated as a normal-priority contribution, so footers from different plugins now compose instead of replacing one another:

Kward.plugin do |plugin|
  plugin.footer do |ctx|
    "#{ctx.session_name || 'unnamed'}"
  end
end

Add an interactive command

Interactive commands take over the composer region with a Kward-driven render and input loop. The plugin receives a controller object with a canvas API for drawing colored cells and reading keys. This is useful for games, dashboards, viewers, and similar full-region interactive experiences.

interactive_command accepts description: and argument_hint: keyword arguments, which appear in the slash command list and completion overlay just like regular plugin commands. rows: sets the fixed canvas height (minimum 1), and fps: sets the target frame rate (1–120, default 30).

Kward.plugin do |plugin|
  plugin.interactive_command "demo", rows: 10, fps: 30, description: "Canvas demo" do |ui, ctx|
    x = 0
    ui.on_tick do |ui|
      ui.clear_frame
      ui.put(0, x, "X", :red)
      x = (x + 1) % ui.width
      key = ui.poll_key
      return :exit if key == :ctrl_c || key == "q"
      ui.render
    end
  end
end

Run it with /demo from the interactive TUI. The canvas renders inside the composer area for the specified number of rows. The transcript above stays intact.

Controller API

The ui controller object passed to the handler block exposes:

Method Description
put(row, col, char, *colors) Place a character at a zero-based position with optional ANSI color styles
clear_frame Reset all canvas cells to blank
render Mark the canvas as ready for Kward to draw
poll_key Return the next pending key (non-blocking, nil if none)
exit Request that the interactive loop exit
`on_tick { \ ui\ }` Register a tick callback invoked each frame at the configured fps
width Canvas width in terminal columns
height Canvas height in terminal rows
fps Target frame rate

Keys are returned as symbols (:left, :right, :up, :down, :return, :backspace, :space, :pageup, :pagedown) or raw strings for keys without a named mapping. Ctrl+C always exits the loop immediately.

The tick callback runs at the configured frame rate (1–120 fps, default 30). Calls to put and clear_frame stage canvas changes; call render after the frame is complete to publish it. Kward skips ticks that do not publish a frame. Returning :exit from the tick callback ends the loop, same as calling ui.exit.

Lifecycle

  • The composer state is saved on entry and fully restored on exit (input text, cursor, and transcript viewport).
  • Interactive mode is a distinct state from the busy-input spinner lifecycle — no spinner or busy chrome is shown.
  • Ctrl+C, the plugin calling exit, or the tick callback returning :exit all exit cleanly and restore the prior composer state.
  • Resize during interactive mode forces a clean exit and restore.

Interactive commands require the TUI prompt interface. They are not available in piped/non-interactive mode or through RPC.

Add a plugin-owned tab

A plugin can provide a persistent tab with Kward's normal composer, transcript rendering, streaming, image input, cancellation, and tab switching. The plugin owns its transcript and model behavior, while Kward can provide scoped storage, configuration, secrets, logging, and resource cleanup. It does not need to use a Kward workspace session.

Kward.plugin(id: "com.example.plugin", version: "1.0.0", api: 1) do |plugin|
  plugin.tab_type(
    "example",
    id: "com.example.chat",
    title: "Example",
    singleton: :global,
    api: 1,
    capabilities: {
      attachments: [:image],
      steering: false,
      transcript_paging: false
    }
  ) do |host, descriptor|
    ExampleChat.new(client: host.client, storage: host.storage, descriptor: descriptor)
  end
end

Open it from interactive Kward:

/tab open example

id is a stable persisted identifier: do not change it after release. Use singleton: :global for one plugin-managed chat shared by all tab views.

Versioned chat contract

Pass api: 1 and capabilities: together to opt into the versioned plugin-chat contract. The capability object supports:

  • attachments: currently [] or [:image];
  • steering: whether the driver supports in-flight steering;
  • transcript_paging: whether the driver implements transcript_page.

Kward validates versioned drivers when they are created. A declared driver must implement messages, submit, descriptor, supports_steering?, and assistant_label. Its supports_steering? result must match the declaration, and a driver declaring transcript paging must implement transcript_page. Omitting both options preserves the legacy method-probing behavior.

The factory's host exposes:

  • host.plugin_id (nil for a legacy anonymous plugin) and host.type_id;
  • host.surface, one of :local, :rpc, :transport, or :shared when the same runtime can serve RPC and transports;
  • host.scope_key, persisted for local tabs and stable for RPC/transport scopes;
  • host.capabilities, the declared contract;
  • host.config, the identified plugin's immutable private configuration;
  • host.storage, isolated by plugin, chat type, and scope;
  • host.secret(name, env: nil) and host.logger;
  • host.background and host.on_cleanup for work owned by this chat instance.

Closing a local tab or shutting down the shared RPC/transport chat runtime calls the driver's optional close method (or shutdown fallback), then cleans up the host's managed resources. Scoped storage remains durable across reconstruction. Legacy plugins receive the same host surface using their stable tab type ID as the configuration namespace.

Plugin tabs do not notify global transcript observers by default. Set transcript_events: true only when the tab explicitly permits its streamed content to be delivered to every installed on_transcript_event handler, such as a local text-to-speech plugin:

plugin.tab_type "example", id: "com.example.chat", transcript_events: true do |host, descriptor|
  ExampleChat.new(client: host.client, descriptor: descriptor)
end

The observer context exposes the plugin tab's messages through ctx.transcript.messages; it has no workspace session. The tab driver returned by the block must provide:

  • messages — renderable transcript messages;
  • submit(input, display_input:, cancellation:, steering:) — a turn method that returns the final response and yields stream events;
  • optionally submit(..., context:) — trusted actor context for transport turns;
  • descriptor — the durable tab descriptor;
  • supports_steering? and assistant_label.

A driver may optionally implement handles_command?(input) and handle_command(input) for its own slash commands. Other Kward session and workspace commands stay unavailable inside plugin tabs.

Set rpc: true when the plugin tab can also be exposed as a trusted local RPC chat:

plugin.tab_type "example", id: "com.example.chat", rpc: true do |host, descriptor|
  ExampleChat.new(client: host.client, descriptor: descriptor)
end

Set transport: true separately when an external transport may target the plugin chat. Do not use rpc: true as an implicit external-access permission:

plugin.tab_type "example", id: "com.example.chat", rpc: true, transport: true do |host, descriptor|
  ExampleChat.new(client: host.client, descriptor: descriptor)
end

Set local: false for a transport-only plugin chat. It remains available to transports when transport: true, but /tab open and local tab restoration do not expose it:

plugin.tab_type "telegram", id: "com.example.telegram", local: false, transport: true do |host, descriptor|
  ExampleChat.new(client: host.client, descriptor: descriptor)
end

Transport-created plugin chats receive a descriptor containing a stable scoped key, transport identity, external conversation, and actor metadata. Use the scope to select separate transcript and memory roots. The driver may accept a context: keyword on submit to receive the authenticated actor for each turn; access decisions must still be enforced by the transport and plugin code, not by model instructions.

RPC clients discover opted-in types through initialize.capabilities.pluginChats, open a chat with pluginChats/open, and must explicitly subscribe before receiving live pluginChat/event notifications. See RPC for the protocol. Plugin tabs remain CLI-only unless they opt in.

Observe transcript events

Use transcript events when you need to log or react to live activity:

Kward.plugin do |plugin|
  plugin.on_transcript_event do |event, ctx|
    next unless event.type == "assistant_delta"

    File.open(File.join(ctx.workspace_root, ".assistant-stream.log"), "a") do |file|
      file.write(event.payload[:delta])
    end
  end
end

Event payloads are read-only copies. Handler errors are caught and printed as warnings.

Common event types include:

  • reasoning_delta
  • reasoning_boundary
  • assistant_delta
  • assistant_message
  • model_retry
  • turn_steered
  • tool_call
  • tool_result
  • answer

Plugin context

Handlers receive a ctx object. Common methods:

  • ctx.workspace_root
  • ctx.args
  • ctx.say(message)
  • ctx.result(message:, data:)
  • ctx.ui
  • ctx.transcript.messages
  • ctx.session_id
  • ctx.session_name
  • ctx.session_path
  • ctx.refresh_system_message!

These methods are available in all handler types, including model-callable tools, although ctx.result is the result contract specifically for typed commands and actions. Typed command and action handlers receive their validated hash through ctx.args; legacy commands continue to receive text. Model-callable tools and asynchronous TUI/RPC slash commands expose ctx.cancellation and ctx.cancelled?. ctx.say outputs to the active frontend (terminal or RPC) wherever it is called. Interactive ctx.ui methods remain capability-gated because not every handler runs in a frontend context that can wait for an answer.

The transcript is read-only. Use context methods instead of mutating Kward internals.

RPC support

Plugins are available in the CLI and RPC backend.

RPC clients can:

  • discover plugin tools through tools/list,
  • invoke plugin tools through normal model turns,
  • list plugin commands through commands/list,
  • run plugin commands through commands/run, including typed object arguments and structured results,
  • run plugin slash commands through turns/start input such as /hello World,
  • discover and run namespaced typed actions through pluginActions/list and pluginActions/run,
  • render and answer structured plugin UI requests advertised through extensionUi.

Plugin command output is emitted through normal turn events without calling the model.

Security

Plugins are local Ruby code. They can read files, write files, run commands, make network requests, and read environment variables as your user. Model-callable plugin tools execute in Kward's host process and are not contained by the command sandbox; permission checks decide whether a call starts but do not sandbox trusted plugin code after it begins.

Recommended practices:

  • Install plugins only from sources you trust.
  • Keep plugins in your personal ~/.kward/plugins directory.
  • Do not put secrets in shared plugin files.
  • Prefer environment variables or private config for credentials.
  • Keep prompt context short and never inject secrets into model prompts.
  • Be careful with transcript observers that persist conversation content.