diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..ea8a6ae51
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,25 @@
+# This repository holds documentation and generated content only.
+#
+# Everything is authored, reviewed and hashed as LF, and the test suite
+# depends on that:
+# - tests/blog-articles.test.mjs compares checked-in image bytes and
+# SHA-256 digests against scripts/blog-articles-manifest.json
+# - tests/seo.test.mjs matches fenced code blocks with LF-only patterns
+# (```yaml\n..., ```text\n...)
+# Without this file a Windows checkout with core.autocrlf=true rewrites
+# every text file to CRLF and those assertions fail for reasons that have
+# nothing to do with the content.
+* text=auto eol=lf
+
+# Explicit binary markers for assets; text=auto already detects these, but
+# stating them keeps the intent obvious and survives future rule changes.
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.webp binary
+*.pdf binary
+*.zip binary
+*.woff binary
+*.woff2 binary
diff --git a/docs.json b/docs.json
index 66e7b13f6..c1724e878 100644
--- a/docs.json
+++ b/docs.json
@@ -20,7 +20,13 @@
"metatags": {
"og:site_name": "LangBot Docs",
"twitter:card": "summary_large_image",
- "og:image": "https://langbot.app/og-image.png"
+ "og:image": "https://langbot.app/docs/images/social/docs-v3.png",
+ "og:image:width": "1200",
+ "og:image:height": "630",
+ "og:image:type": "image/png",
+ "og:image:alt": "LangBot Docs — Build. Connect. Extend.",
+ "twitter:image": "https://langbot.app/docs/images/social/docs-v3.png",
+ "twitter:image:alt": "LangBot Docs — Build. Connect. Extend."
}
},
"navbar": {
@@ -677,6 +683,7 @@
"group": "Component Development",
"pages": [
"en/plugin/dev/components/add",
+ "en/plugin/dev/components/runner",
"en/plugin/dev/components/event-listener",
"en/plugin/dev/components/command",
"en/plugin/dev/components/tool",
@@ -686,20 +693,23 @@
]
},
"en/plugin/dev/style",
- "en/plugin/dev/migration",
+ "en/plugin/certified-plugins",
{
"group": "Publish Plugin",
"pages": [
"en/plugin/dev/publish/market",
"en/plugin/dev/publish/github"
]
- }
+ },
+ "en/plugin/dev/migration"
]
},
{
"group": "Plugin SDK API",
"pages": [
+ "en/plugin/dev/apis/agent-run",
"en/plugin/dev/apis/common",
+ "en/plugin/dev/apis/platform",
"en/plugin/dev/apis/pipeline-events",
"en/plugin/dev/apis/messages",
"en/plugin/dev/apis/tech-details"
@@ -980,6 +990,7 @@
"group": "组件开发",
"pages": [
"zh/plugin/dev/components/add",
+ "zh/plugin/dev/components/runner",
"zh/plugin/dev/components/event-listener",
"zh/plugin/dev/components/command",
"zh/plugin/dev/components/tool",
@@ -989,20 +1000,23 @@
]
},
"zh/plugin/dev/style",
- "zh/plugin/dev/migration",
+ "zh/plugin/certified-plugins",
{
"group": "发布插件",
"pages": [
"zh/plugin/dev/publish/market",
"zh/plugin/dev/publish/github"
]
- }
+ },
+ "zh/plugin/dev/migration"
]
},
{
"group": "插件 SDK API",
"pages": [
+ "zh/plugin/dev/apis/agent-run",
"zh/plugin/dev/apis/common",
+ "zh/plugin/dev/apis/platform",
"zh/plugin/dev/apis/pipeline-events",
"zh/plugin/dev/apis/messages",
"zh/plugin/dev/apis/tech-details"
@@ -1286,6 +1300,7 @@
"group": "コンポーネント開発",
"pages": [
"ja/plugin/dev/components/add",
+ "ja/plugin/dev/components/runner",
"ja/plugin/dev/components/event-listener",
"ja/plugin/dev/components/command",
"ja/plugin/dev/components/tool",
@@ -1295,20 +1310,23 @@
]
},
"ja/plugin/dev/style",
- "ja/plugin/dev/migration",
+ "ja/plugin/certified-plugins",
{
"group": "プラグインを配布",
"pages": [
"ja/plugin/dev/publish/market",
"ja/plugin/dev/publish/github"
]
- }
+ },
+ "ja/plugin/dev/migration"
]
},
{
"group": "プラグイン SDK API",
"pages": [
+ "ja/plugin/dev/apis/agent-run",
"ja/plugin/dev/apis/common",
+ "ja/plugin/dev/apis/platform",
"ja/plugin/dev/apis/pipeline-events",
"ja/plugin/dev/apis/messages",
"ja/plugin/dev/apis/tech-details"
diff --git a/en/deploy/settings.mdx b/en/deploy/settings.mdx
index 0a7f7925e..0ad0abfb5 100644
--- a/en/deploy/settings.mdx
+++ b/en/deploy/settings.mdx
@@ -484,3 +484,48 @@ At startup, LangBot applies these overrides and writes the resulting configurati
In Docker deployments, set unified `BOX__*` variables on the `langbot` service. LangBot sends Box configuration to `langbot_box` through INIT RPC; variables set directly on `langbot_box` are not read.
+
+## Optional telemetry
+
+Telemetry is enabled by default and can be disabled.
+
+Set the following in the existing `space` section of `data/config.yaml`, preserve other settings, and restart LangBot:
+
+```yaml
+space:
+ disable_telemetry: true
+```
+
+This switch disables product telemetry (including heartbeats and execution records) and survey fetching. Use an unquoted YAML boolean. To restore the default, set it to `false` and restart. Cloud Plugin Runtime operational health reporting is controlled separately by `plugin.runtime_ops.enabled`.
+
+If `SPACE__DISABLE_TELEMETRY` is set in your deployment, it overrides the file at startup; update or remove it too. Recreate containers when changing their environment. In Cloud, the instance operator controls this setting for every Workspace in the instance. It does not disable the bot or change privacy settings of messaging platforms, model providers or plugins.
+
+### Per-event execution chains
+
+Current 4.11 execution telemetry defaults to full reporting of eligible executions, covering platform events, routing, Pipeline / Agent / plugin event processors, and the platform API and tool calls they cause. Each completed node generates a record, followed by a closure record when the execution ends; Space reconstructs the chain. These are the defaults in the same `space` section; restart after changing them:
+
+Platform events without a matching business route, or ignored by a `discard` rule with no matching plugin subscription, upload no execution records. Local logs and monitoring keep their existing behavior. Matching subscriptions, valid interaction resumptions and independent WebUI/API/Pipeline calls remain eligible, as do unavailable targets, execution failures or Pipeline filtering after a route matches. Eligibility is checked before sampling; neither Cloud's `all` nor failure fallback bypasses it. A Core upgrade is required.
+
+```yaml
+space:
+ execution_trace: all # off | failures | sampled | all
+ execution_trace_sample: 20 # sampled: retain about 1/N successful executions by event hash
+ telemetry_flush_seconds: 180 # Execution record buffer flush interval, in seconds
+```
+
+The community edition supports these modes:
+
+| Mode | Reporting behavior |
+|---|---|
+| `all` | Default; records nodes and closure records for every eligible execution |
+| `sampled` | Makes one stable hash decision from the event identity for all records of that execution; excluded executions that fail still report a closure, without recovering skipped nodes |
+| `failures` | Omits nodes for ordinary executions; reports closures only for failure, cancellation, timeout or a recorded chain failure reason |
+| `off` | Reports neither nodes nor closures; no independent window counters continue, while heartbeats remain controlled by `disable_telemetry` |
+
+WebUI debug runs are retained with a debug flag in `all`, `sampled` and `failures`; `off` or `disable_telemetry: true` stops their upload. **Cloud always uses `all` while product telemetry is enabled and does not apply the sampling mode configured above**; the instance operator can still disable product telemetry with the main switch.
+
+`execution_trace_sample` accepts integers from 1 to 100000; `telemetry_flush_seconds` accepts integers from 1 to 86400. Invalid values fall back to 20 and 180 respectively, and an invalid mode falls back to `all`. Sampling does not select exactly every N-th execution in sequence.
+
+Each flush sends at most 128 records and the buffer holds at most 512. Failed sends remain buffered for retries; a full buffer drops the oldest records, so full reporting does not guarantee complete delivery. Active Workspaces first send a heartbeat about 30 seconds after startup, then daily in ordinary deployments or every 5 minutes in Cloud runtime. Raw execution records are cleaned up in batches using a 30-day window; backlogs can delay deletion.
+
+See [Data Collection Policy](../insight/data-collection-policy).
diff --git a/en/insight/data-collection-policy.mdx b/en/insight/data-collection-policy.mdx
index 649433e89..f511507c4 100644
--- a/en/insight/data-collection-policy.mdx
+++ b/en/insight/data-collection-policy.mdx
@@ -1,87 +1,78 @@
---
title: "Data Collection Policy"
-description: "Existing telemetry, Beta diagnostics, retention and opt-out controls"
+description: "Optional telemetry, execution chains, collected data and opt-out controls"
---
-LangBot sends telemetry to the configured LangBot Space service (by default `https://space.langbot.app`) to understand usage and improve reliability. **Existing product telemetry and Beta diagnostics are separate data streams.** This page describes Core 4.11.0-beta.2 diagnostics schema v1; it does not change existing product analytics settings.
+LangBot sends product telemetry to the LangBot Space service configured by `space.url` (by default `https://space.langbot.app`) to understand usage and improve reliability. **Telemetry is enabled by default and can be disabled.** This page describes the current 4.11 execution telemetry protocol (`schema: 2`); older-version data is described separately below.
-## Existing product telemetry
+## Execution telemetry
-When enabled, the existing query path can send:
+An eligible execution consists of independent records sharing an `event_id`: one `execution_node` is generated when each workflow node completes, and one `execution_chain` when the execution closes. Core buffers records within a fixed limit and uploads them in background batches; Space reconstructs the execution chain. The current sender no longer uses legacy feature window counters or assembles a complete chain locally.
+
+**Platform events without a matching business route, or whose selected route is `discard` with no matching plugin subscription, report neither nodes nor a closure.** Disabled routes and event-type/filter mismatches are included; local logs and monitoring keep their existing behavior. A matching plugin event processor still makes an execution eligible when the primary route misses. Unmatched and discarded branches do not contribute telemetry.
+
+After a business route matches, unavailable targets, execution failures, timeouts and filtering inside a Pipeline remain reportable. Valid interaction callbacks resuming an original processor, WebUI/API debug runs and independent calls explicitly targeting a Pipeline also remain eligible; invalid callbacks do not independently qualify. Eligibility takes precedence over sampling and failure fallback, including in Cloud. This requires a Core upgrade and does not retroactively change older clients or historical records.
| Data | Contents |
|---|---|
-| Identity and environment | `instance_id`, `workspace_uuid`, random `query_id`, version, adapter and runner types |
-| Query statistics | Model name, duration, timestamp, pipeline plugin names |
-| Feature usage | Tool counts by source, tool-call rounds, knowledge-base and retrieved-entry counts, knowledge-engine plugin IDs, sandbox execution/error counts, activated skill names and configured MCP server names |
-| Errors | The legacy `error` field can contain a raw traceback, including exception text |
-| Managed runtime activity | `runtime_instance_id` on managed SaaS queries refreshes idle-timeout activity; it is not stored in the analytics event record |
+| Identity and environment | `instance_id`, `workspace_uuid`, `event_id`, version, edition, timestamp, debug flag and sampling information |
+| Execution nodes | Operation identifiers, processing mode, outcome, start/end times, parent/child relationships and sequence, routing target and Runner run identity for platform events, routing, Pipeline, Runner, platform API and tool calls |
+| Chain closure | Overall outcome, closure reason, duration, node count, nodes omitted by sampling, origin, model name, adapter, Runner and pipeline plugins |
+| Bound resources | Identifiers or names of plugins, MCP servers, knowledge bases, tools and their sources, skills, models and platform APIs granted to a Pipeline or Agent, plus selector fields, capabilities and permission flags; fields depend on the execution path |
+| Errors | Short error text with length limits and whitespace normalization, potentially including an exception class and message prefix |
-Feature counters do not intentionally include tool arguments/results, retrieved text or sandbox code/output. **However, legacy exception text and user-defined model, skill or MCP names may contain sensitive information. The new content-free diagnostics rules do not sanitize this legacy stream.** Do not treat existing telemetry as guaranteed content-free.
+Bound resources describe what an execution was configured or authorized to use. **They do not establish that those resources were actually called, searched or consumed.** Routing targets use `type:uuid`, not user-defined rule names.
-A Workspace heartbeat is sent shortly after startup and daily for active Workspaces. It includes deployment platform, database/vector-database kinds, sandbox configuration and availability, enabled adapter types and resource counts, rather than resource contents or connection details.
+Execution records do not intentionally collect message bodies, prompts, model replies, platform user/group IDs, tool/API arguments or results, credentials or complete exception tracebacks. **Truncating error text is not reliable redaction: exception prefixes and user-defined model, skill or resource names may still contain sensitive information.** Instance and Workspace identifiers may be associated with deployments or accounts; these records are neither anonymous nor guaranteed to contain no user content.
-Optional in-app surveys send the survey ID, submitted answers, instance ID, version and completion/dismissal status. Survey answers are user-provided content, not Beta diagnostics.
+### Sampling and debug runs
-## Beta diagnostics v1 — 4.11.0-beta.2
+The community edition controls execution reporting with `space.execution_trace`:
-Beta diagnostics are **on by default in Beta builds only**. Stable, alpha and release-candidate builds do not enable this producer; this release has no stable opt-in. There is no additional setup, consent wizard or required testing step. Collection runs in the background with bounded queues, batches, timeouts and retries; unavailable diagnostics services must not hold up normal bot use. Queue limits and network failures can cause diagnostic records to be lost.
+| Mode | Behavior |
+|---|---|
+| `all` (default) | Records nodes and closure records for every eligible execution |
+| `sampled` | Makes one stable hash decision from the event identity at ingress; retains approximately `1/N` successful executions, where N is `space.execution_trace_sample` (default 20); every record of an execution shares the decision |
+| `failures` | Omits nodes for ordinary executions and reports a closure only for failure, cancellation, timeout or a recorded chain failure reason |
+| `off` | Disables node and closure records; no independent window counters continue, while Workspace heartbeats remain controlled by the main telemetry switch |
-Diagnostics record only allowlisted, content-free metadata from instrumented operations:
+If an execution excluded by `sampled` fails, its closure is still reported. Previously skipped nodes are not recovered, so a failure may have a summary without a complete chain. WebUI debug runs are retained in `all`, `sampled` and `failures` with a debug flag; neither `off` nor the main opt-out permits their upload.
-- Core/SDK/public plugin or runner versions, build revision, deployment/environment categories and supported/configured capabilities.
-- Operation and event categories, processor type, execution stage, observed outcome, counts and duration.
-- Fixed reason codes, error categories and fingerprints built from code identifiers, **not exception messages or raw tracebacks**.
-- Independent `instance_id` and optional `workspace_uuid`, plus opaque event/trace/span/run identifiers to correlate diagnostic steps. Instance-level events may have no Workspace.
-- Sender health counters such as generated, acknowledged, retried and dropped records.
+**Cloud always uses `all` while product telemetry is enabled**, so sampling successful executions does not distort activity counts. The instance operator can still disable the product telemetry described here with `space.disable_telemetry`.
-**These are pseudonymous identifiers, not anonymous data.** An instance and a Workspace are distinct identity dimensions; neither is a substitute for the other. Identifiers may be associated with an account or deployment. “Unauthenticated reporter” does not mean unidentifiable.
+### Delivery and completeness
-### Excluded from Beta diagnostic records
+Records are sent to `POST /api/v1/telemetry`. The default flush interval is 180 seconds, configurable through `space.telemetry_flush_seconds`. Each flush sends at most 128 records and the memory buffer holds at most 512. Records are removed only after an accepted send; failed sends are retried, and a full buffer drops the oldest records. Shutdown attempts to flush remaining records within a limited time.
-No messages, prompts, model replies, raw platform events, tool/API arguments or results, form values, file/media contents, filenames or local paths, raw URLs/query strings, credentials, platform user/group IDs, user-defined resource names, or raw exceptions/tracebacks. Public component IDs and fixed operation categories are used instead of arbitrary text. Network infrastructure necessarily sees a sender's IP address for delivery and abuse prevention; this is not a promise that all service access logs are IP-free.
+Network failures, timeouts and server errors do not fail business execution because telemetry failed; upload failure details are logged at DEBUG level. Sampling, buffer overflow, process exit or expired cross-task associations can leave incomplete chains. **Full reporting does not guarantee complete delivery.** A successful platform API response does not prove that a user read a message.
-Diagnostics help find compatibility failures and regressions in observed paths. They are separate from query usage, retention/funnel statistics, account bindings and managed runtime idle-timeout activity. A platform API accepting a request does **not** prove a user received or read a message. Missing records or an untested path do not demonstrate success.
+## Heartbeats, surveys and older-version data
+
+Active Workspaces first send an `instance_heartbeat` about 30 seconds after startup. Ordinary deployments send daily thereafter; Cloud runtime refreshes every 5 minutes. Heartbeats include deployment platform, database/vector-database kinds, sandbox configuration and availability, adapter types and counts of resources such as bots, Pipelines, plugins, Agents, event processors, event bindings, MCP servers, knowledge bases and skills. They do not intentionally include resource contents or connection credentials.
+
+Optional in-app surveys send the survey ID, submitted answers, instance ID, version and completion/dismissal status. Survey answers are content voluntarily provided by users.
+
+Space remains compatible with older `query` and `feature_execution` data. Legacy query records may contain model names, feature counters, user-defined skill/MCP names and raw traceback text. The current execution-record restrictions neither sanitize historical data nor change uploads from older clients still running.
## Storage and retention
-Data sent to the default service is stored on servers operated for LangBot Space. Beta diagnostics can be read by administrators, members explicitly authorized for telemetry access, and authorized service API clients. Ordinary Workspace membership does not itself grant this access. **Server default cleanup thresholds are 30 days for diagnostic detail and 90 days for aggregates**, with configuration bounded by those limits. Records become eligible for deletion at these thresholds; a scheduled worker removes them in bounded batches. Backlogs or service interruptions can delay removal, so these are not guarantees of deletion at an exact hour. These periods apply to the new diagnostic stream, not retroactively to existing product telemetry, surveys, service logs or account records. Existing telemetry continues under its existing product-analytics retention settings.
+Data is stored by the configured Space service. Execution lists, chain details and summaries are restricted to authorized telemetry management users or service clients. Raw `execution_node`, `execution_chain` and legacy `feature_execution` records are periodically removed in bounded batches using a 30-day retention window; backlogs or service interruptions can delay deletion. This does not change retention for legacy query telemetry, heartbeats, surveys, accounts or service logs.
-Opting out stops future diagnostic sends and clears local pending diagnostic records once the setting takes effect. It does **not** delete records already received by the server. For historical deletion, contact [hello@langbot.app](mailto:hello@langbot.app) or your Space administrator; active administrators and authorized service API clients can delete diagnostics scoped to an instance and, where applicable, a Workspace. Telemetry read permission alone does not permit deletion or error-status changes. Share identifiers privately, not in a public issue.
+Opting out stops future reporting but does not delete existing server records. For historical data requests, contact [hello@langbot.app](mailto:hello@langbot.app) or your Space administrator.
-## Opt-out controls
+## Disabling telemetry
-To disable only Beta diagnostics, update the existing `space` section in `data/config.yaml`, preserving your other settings, then restart LangBot:
-
-```yaml
-space:
- disable_beta_diagnostics: true
-```
-
-This does not change query telemetry, heartbeat, survey or other product analytics settings.
-
-To disable Core telemetry, including Beta diagnostics, use the master switch and restart:
+Set the following in the existing `space` section of `data/config.yaml`, preserve other settings, and restart LangBot:
```yaml
space:
disable_telemetry: true
```
-The master switch takes precedence over the Beta setting. It also disables the existing query/heartbeat sends and survey fetching. Use YAML booleans (`true`/`false`), not quoted strings. If your deployment sets `SPACE__DISABLE_BETA_DIAGNOSTICS` or `SPACE__DISABLE_TELEMETRY`, those environment values override the file at startup; update or remove them too, then restart the process (recreate a container when changing its environment). Diagnostics also require a configured `space.url`; local-version builds such as `4.11.0b2+local` do not enable the producer. These controls do not disable the bot or alter the privacy settings of your messaging platforms, plugins, model providers or other online services.
+This switch disables the product telemetry described on this page and survey fetching. Use an unquoted YAML boolean. To restore the default, set it to `false` and restart.
-## Optional Beta test checklist — 4.11.0-beta.2 / v1
+If `SPACE__DISABLE_TELEMETRY` is set in your deployment, it overrides the file at startup; update or remove it too. Recreate containers when changing their environment. In Cloud, the instance operator controls this setting for every Workspace in the instance. It does not disable the bot or change privacy settings of messaging platforms, model providers or plugins.
-Use a test bot, test Workspace and non-sensitive sample data. Only test capabilities your installed adapter and plugins support. This checklist is optional; diagnostics do not automatically run these actions.
+Cloud Plugin Runtime operational health reporting is separate from product telemetry. It is controlled by `plugin.runtime_ops.enabled` and is not disabled by `space.disable_telemetry`.
-| Scenario ID | What to check |
-|---|---|
-| `B2-01` | Start/restart the installation; check platform and Runtime connections and one normal reply. |
-| `B2-02` | Trigger a matching and non-matching route; where configured, test Pipeline, Agent and plugin event processors separately. |
-| `B2-03` | Exercise a configured tool/plugin/MCP call and a reply; check the actual destination and content yourself. |
-| `B2-04` | In a safe test environment, cancel a run or reproduce a known failure; confirm the bot remains usable. |
-| `B2-05` | If supported, test streaming and an interaction callback; check partial replies, timeout and resume behavior. |
-| `B2-06` | If checking diagnostics controls, disable diagnostics, restart and confirm normal bot use. A maintainer can separately verify no new diagnostic requests and that pending records are cleared. |
-
-Do not kick, mute, delete or otherwise modify production users/data just to create diagnostic samples. Report the scenario ID, exact Core/SDK versions, platform, approximate UTC time, expected versus actual behavior and whether it was a real platform or WebUI debug test. Remove secrets and personal data from any manually submitted logs/screenshots. After a fix, repeat the same scenario on the new build. Diagnostic coverage supplements these checks; it does not replace them.
-
-*Policy version: 1.5 · Last updated: September 15, 2026*
+See [System Settings](../deploy/settings).
diff --git a/en/plugin/certified-plugins.mdx b/en/plugin/certified-plugins.mdx
new file mode 100644
index 000000000..29b174379
--- /dev/null
+++ b/en/plugin/certified-plugins.mdx
@@ -0,0 +1,227 @@
+---
+title: "Certified plugins and shared runtime"
+description: "What certification is, why it exists, when you need it, and the shared-runtime-v1 / stateless-v1 contract."
+---
+
+## What certification is
+
+Certification is a grant LangBot makes for **one exact, immutable plugin artifact**: that artifact is approved to run **shared across tenants** in LangBot Cloud.
+
+- Certification is bound to the artifact itself (a specific published version, identified by digest), not to a plugin name or an author. Publishing a new version does not carry the previous version's certification over.
+- Certification is not a quality score and not a security audit endorsement.
+
+## Why certification exists
+
+LangBot does not place plugins in shared mode by default. The reason is that sharing is keyed by artifact:
+
+- **Dedicated mode**: the plugin does not participate in sharing and runs on its own.
+- **Shared mode**: every installation of the same artifact digest shares one Worker.
+
+Sharing keeps the number of processes and the startup cost from growing linearly with the number of installations. The trade-off is that **one process handles calls from several Workspaces at once** — so a plugin that keeps tenant data on its instances will leak it between tenants.
+
+The platform therefore has to review this exact artifact, confirm it satisfies the stateless contract, and then pin that trust to the artifact with a signature before it may enter shared placement. That is what certification is for.
+
+## When you need certification
+
+**Most plugins do not need certification.** Skipping it does not stop you from publishing to the marketplace, and it does not stop users from installing your plugin — it simply runs in dedicated mode.
+
+| Your situation | Certification needed? |
+| --- | --- |
+| You only want to publish the plugin so users can install it | No |
+| The plugin needs process-local state across calls (for example a long-lived external session process) | No — and it should stay dedicated |
+| The plugin has background tasks, timers, or long-lived tenant caches | No — dedicated mode is the safer choice |
+| You want the Certified Plugin badge | Yes |
+| You want several installations of one artifact to share a process, reducing cost and startup overhead | Yes |
+| The plugin targets Cloud multi-tenant scenarios and you want shared placement | Yes |
+
+The test is a single question: **certification only makes sense if your code genuinely keeps no tenant or installation data on its instances.**
+
+## What certification changes
+
+After review passes:
+
+- The artifact is signed and installed in **shared mode**; multiple installations of the same digest share one Worker.
+- That Worker holds one `BasePlugin` object and one instance per declared component manifest (EventListener, Tool, Command, KnowledgeEngine, Parser, Page, Runner).
+- Each installation adds only a lightweight layer of configuration, authority and lifecycle context; the active one is selected per call.
+- You receive the Certified Plugin badge.
+- Self-hosted OSS ships without a certification issuer public key, so a certified artifact still runs dedicated on self-hosted instances.
+
+You also take on a standing obligation: **every later version must keep satisfying the stateless contract.** After changing code you submit a new version for review; certification does not renew itself.
+
+## What you must adapt
+
+### Declaration
+
+Declare both fields under `execution` in `manifest.yaml`:
+
+```yaml
+apiVersion: v1
+kind: Plugin
+metadata:
+ author: your-name
+ name: YourPlugin
+ version: 0.1.1
+spec:
+ config: []
+ components: {}
+execution:
+ python:
+ path: main.py
+ attr: YourPlugin
+ sharedRuntime: shared-runtime-v1
+ componentModel: stateless-v1
+```
+
+| Field | Accepted value | Notes |
+| --- | --- | --- |
+| `execution.sharedRuntime` | `shared-runtime-v1` | This is the only accepted value; anything else fails manifest validation |
+| `execution.componentModel` | `stateless-v1` | This is the only accepted value |
+
+- Both fields **must be declared together**. A package that declares `sharedRuntime` without `componentModel` is rejected during certification validation.
+- The fields are recognised from **SDK 0.7.0** onwards. Earlier SDKs ignore them, so do not rely on an old SDK to tell you whether the declaration took effect.
+
+
+A declaration is an application, not approval. It grants no badge and does not change how the plugin runs by itself.
+
+
+### Stateless constraints
+
+- `BasePlugin.initialize()` and component `initialize()` are **process-scoped** and run once per Worker. At that point there is no "current installation" and no "current Workspace".
+- Component instances are called concurrently by different Workspaces, so every method must be re-entrant.
+- Read the active installation configuration per call: `self.get_config()` on `BasePlugin`, `self.get_plugin_config()` or `self.plugin.get_config()` in a component, and `ctx.config` in a Runner.
+- Never store configuration, credentials, Workspace or installation identity, or request data in instance fields or module-level globals.
+- Do not read and cache installation configuration in `initialize()`.
+- Reach storage, files, credentials, logs, models and other platform capabilities through the SDK Host APIs. Authority is resolved per call; do not cache it yourself.
+- Durable tenant state belongs in Host services. Runners use `ctx.state_get()` / `ctx.state_set()` and friends (this requires `ctx.context.available_apis.state` to be `true`). In-process caches may hold artifact-level public data only; if you must cache tenant data, the key has to include the complete installation identity and must be cleaned up when the installation is removed.
+- Do not start tenant-bearing background tasks or threads: the SDK does **not** track ownership of work you create. If you must detach work, keep a registry keyed by the installation binding and stop it in `on_installation_revoked()`. Once a handler returns, that invocation's context and run-associated calls are no longer valid.
+
+### One installation, many usages
+
+A Workspace installs each plugin once, but that single installation is reused by **several product objects**:
+
+| Component kind | What reuses it | What isolates it |
+| --- | --- | --- |
+| Runner in an Agent or Pipeline | every processor | `ctx.state.*` is already per-processor (scopes carry `binding_identity`, derived from the Agent UUID) |
+| KnowledgeEngine / Parser behind a knowledge base | every knowledge base | **your plugin must isolate by knowledge-base ID itself** |
+| Tool / Command / EventListener / Page | several processors, conversations or page requests | read per call; never accumulate on the instance |
+
+The platform storage key is `(instance, Workspace, owner type, plugin, key)` and carries **no processor or knowledge-base dimension**. So whenever your plugin persists or caches anything, it must put the product object's identity into the key:
+
+- **Config**: read per call (`ctx.config`, or the task-local `self.get_plugin_config()`); never cache it on the instance.
+- **Persistence**: keys must include the product identity (processor identity / knowledge-base ID / conversation identity).
+- **Process-local caches**: keys must include both the full installation binding **and** the product identity, and must be released when the installation is revoked (below).
+- **Locks and fences**: key them by `(installation, product object)`. **A failure in one processor or knowledge base must not block the others of the same installation.**
+- **Failure grading**: only a dispatched mutation with an unknown outcome should enter a state that needs manual reconciliation; deterministic validation errors and pre-dispatch cancellation must not affect later calls.
+- **Logs and telemetry**: a diagnostic failure must not affect the work, and tenant data must not be written into a process-wide singleton.
+
+### When an installation is revoked
+
+Under shared placement one object graph serves every installation of the artifact, so **revoking one installation does not destroy it**. The plugin must release binding-keyed process-local state itself:
+
+```python
+class MyPlugin(BasePlugin):
+ def __init__(self) -> None:
+ super().__init__()
+ self._caches: dict[str, Cache] = {}
+
+ async def on_installation_revoked(self, binding) -> None:
+ self._caches.pop(binding.installation_uuid, None)
+```
+
+- **Fires for** uninstall, disable, Workspace removal and upgrade (the superseded binding is revoked). It runs only under shared placement; dedicated workers are shut down, so their process-local state disappears with the process.
+- **There is no active invocation**, so Host APIs (storage, config, binding lookups) are unavailable — you can only release process-local state, and the installation is identified by the argument.
+- Exceptions are logged and **never block revocation**.
+- It requires an SDK version that provides `BasePlugin.on_installation_revoked`; on older versions the hook is simply never called.
+
+### Allowed / forbidden
+
+| Concern | Allowed | Forbidden |
+| --- | --- | --- |
+| Reading config | Read per call (`self.get_plugin_config()`, `ctx.config`) | Reading in `initialize()` and keeping it on `self` |
+| Connection pools / HTTP clients | Shared pools without tenant credentials | Baking an installation's endpoint or API key into the instance |
+| Caching | Artifact-level public data; or keys including the full installation identity and supporting cleanup | A module-level `dict` keyed by Workspace or user that persists |
+| Cross-invocation state | Host services (`ctx.state_*` in a Runner) | Instance fields, module globals |
+| Background work | None | Holding an invocation's context or its run-associated calls after the handler returned |
+| Logging | `ctx.log()` or SDK logging APIs | A module-level background thread printing tenant config or credentials |
+
+### Common anti-patterns
+
+**1. Reading installation config during initialization**
+
+```python
+async def initialize(self):
+ self.endpoint = self.plugin.config["endpoint"] # process-scoped init has no active installation
+ self.api_key = self.plugin.config["api_key"]
+```
+
+Shared initialization has no installation configuration, so this reads empty or stale values.
+
+**2. Caching tenant data in a module global**
+
+```python
+_CACHE = {} # keyed by Workspace or user; persists across installations and tenants
+
+async def call(self, params, **kwargs):
+ if params["user"] not in _CACHE:
+ _CACHE[params["user"]] = await fetch(params["user"])
+```
+
+**3. Keeping work alive after the handler returns**
+
+```python
+async def call(self, params, **kwargs):
+ config = self.get_plugin_config()
+ asyncio.create_task(self._upload_later(config)) # handler already returned; context and authority are gone
+ return ok()
+```
+
+**4. Treating instance fields as a config container**
+
+```python
+async def initialize(self):
+ self.default_endpoint = "https://api.example.com" # a public constant is fine
+ self.last_config = {} # writing installation config here is not
+```
+
+The correct shape:
+
+```python
+class LookupTool(Tool):
+ async def initialize(self):
+ # artifact-level objects only, no tenant information
+ self.transport = StatelessHTTPTransport()
+
+ async def call(self, params, **kwargs):
+ config = self.get_plugin_config() # read for the active installation on every call
+ return await self.transport.lookup(config["endpoint"], params)
+```
+
+### Runners in particular
+
+Runners have the most tension with the stateless contract, because a runner naturally tends to keep sessions and context in the process.
+
+- A `RunnerContext` is **valid only for the current run**. Do not keep it, or anything derived from it, on the instance for reuse across calls.
+- Session state a Runner needs across calls belongs in `ctx.state_get()` / `ctx.state_set()` (scopes: `conversation`, `actor`, `subject`, `runner`) or another Host-provided store — not in instance fields.
+- After a handler returns, do not keep using `ctx` in background tasks, and do not keep using run-associated calls made through `self.plugin`.
+- If your Runner fundamentally depends on process-local state across calls (for example a long-lived external session process), **do not declare `stateless-v1`** and stay on a dedicated Worker.
+
+See [Runner context](/en/plugin/dev/apis/agent-run) for the full context API.
+
+## Self-check and submission
+
+Verify each item before submitting:
+
+- [ ] Both fields are declared in `manifest.yaml` with the exact accepted values.
+- [ ] A repository-wide search confirms there are no module-level mutable globals.
+- [ ] `initialize()` performs process-scoped setup only and reads no installation config.
+- [ ] Every piece of installation config is read at call time.
+- [ ] No task or thread keeps running after a handler returns.
+- [ ] Cross-call state goes through Host APIs (`ctx.state_*` in a Runner).
+
+### How to submit
+
+Certification uses the same path as publishing, with no extra process: declare the two fields above in `manifest.yaml`, then submit a **new version** with `lbp publish` as usual (a published version cannot have the declaration added later). LangBot Space checks the exact submitted package, runs an advisory code review, and issues the Certified Plugin badge after human approval; certification is bound to the reviewed version and checksum.
+
+If the review finds that the release cannot meet the requirements, publish it as an unsigned normal plugin first and fix the findings in a later immutable version.
+
+If this check shows your plugin is not a good fit for sharing, just publish it as a normal plugin — certification is optional. See [Publish to the marketplace](/en/plugin/dev/publish/market) for the publishing flow.
diff --git a/en/plugin/dev/apis/agent-run.mdx b/en/plugin/dev/apis/agent-run.mdx
new file mode 100644
index 000000000..58bcf069f
--- /dev/null
+++ b/en/plugin/dev/apis/agent-run.mdx
@@ -0,0 +1,261 @@
+---
+title: "Context APIs"
+---
+
+A context contains data for one component invocation. Each component receives a different context, so start with the entry point for your component.
+
+| Component | Handler entry point | Context type |
+| --- | --- | --- |
+| EventListener | `handler(event_context)` | `EventContext` |
+| Command | `subcommand(context)` | `ExecuteContext` |
+| Tool | `call(params, session, query_id)` | No separate context object |
+| Runner | `run(ctx)` or `handler(ctx)` | `RunnerContext` |
+| KnowledgeEngine | `ingest(context)`, `retrieve(context)` | `IngestionContext`, `RetrievalContext` |
+| KnowledgeRetriever | `retrieve(context)` | `RetrievalContext` |
+| Parser | `parse(context)` | `ParseContext` |
+| Page | `handle_api(request)` | `PageRequest` |
+
+This page covers the data and methods available through these contexts. Use `self.plugin` for models, knowledge bases, storage, and other LangBot capabilities; see [LangBot API](/en/plugin/dev/apis/common). For platform operations, see [Platform API](/en/plugin/dev/apis/platform).
+
+`EventContext`, `ExecuteContext`, and `Session` also carry `instance_uuid`, `workspace_uuid`, and `placement_generation`. These fields identify the current instance and workspace for execution; plugins must not treat them as authorization data.
+
+## EventListener
+
+EventListener handles Pipeline events. Its event handler receives an `EventContext`:
+
+```python
+event = event_context.event
+sender_id = event.sender_id
+```
+
+### Context data
+
+| Property | Type | Description |
+| --- | --- | --- |
+| `event` | `BaseEventModel` | Current event object; its actual type matches the registered event type |
+| `event_name` | `str` | Current event class name |
+| `query_id` | `int` | Current Pipeline request ID |
+| `query_uuid` | `str \| None` | Stable identifier for the current Pipeline request |
+| `eid` | `int` | Temporary event number inside Plugin Runtime; do not persist it |
+| `is_prevent_default` | `bool` | Whether default handling has been stopped; change it with `prevent_default()` |
+| `is_prevent_postorder` | `bool` | Whether later plugins have been stopped; change it with `prevent_postorder()` |
+
+See [Pipeline Events](/en/plugin/dev/apis/pipeline-events) for event fields and all supported event types.
+
+### Context methods
+
+| Method | Return value | Description |
+| --- | --- | --- |
+| `await event_context.reply(message_chain, quote_origin=False)` | - | Reply in the conversation for the current request |
+| `await event_context.get_bot_uuid()` | `str` | Get the source bot UUID |
+| `await event_context.set_query_var(key, value)` | - | Set a variable on the current request |
+| `await event_context.get_query_var(key)` | `Any` | Get one request variable |
+| `await event_context.get_query_vars()` | `dict[str, Any]` | Get all request variables |
+| `await event_context.create_new_conversation()` | `dict[str, Any]` | Clear the current Pipeline conversation so a later request starts a new one |
+| `await event_context.list_pipeline_knowledge_bases()` | `list[dict[str, Any]]` | List knowledge bases bound to the current Pipeline Local Agent |
+| `await event_context.retrieve_knowledge(kb_id, query_text, top_k=5, filters=None)` | `list[dict[str, Any]]` | Search a knowledge base bound to the current Pipeline |
+| `event_context.prevent_default()` | - | Stop the default handling of the current event |
+| `event_context.prevent_postorder()` | - | Stop later plugins from handling the current event |
+
+`reply()` accepts a [MessageChain](/en/plugin/dev/apis/messages). Request variables, replies, and Pipeline knowledge-base methods require an associated Pipeline request. The `kb_id` passed to `retrieve_knowledge()` must come from `list_pipeline_knowledge_bases()`.
+
+`prevent_default()` only affects Pipeline events whose default flow can be stopped. See [Pipeline Events](/en/plugin/dev/apis/pipeline-events#prevent-default-behavior).
+
+## Command
+
+A Command subcommand handler receives an `ExecuteContext`:
+
+```python
+query = " ".join(context.crt_params)
+session = context.session
+```
+
+### Context data
+
+| Property | Type | Description |
+| --- | --- | --- |
+| `session` | `Session` | Session that contains the current message |
+| `command_text` | `str` | Complete command text without the command prefix |
+| `full_command_text` | `str` | Complete command text including the prefix |
+| `command` | `str` | Root command name |
+| `crt_command` | `str` | Subcommand currently being executed |
+| `params` | `list[str]` | All arguments after the root command |
+| `crt_params` | `list[str]` | Arguments not yet consumed by the current subcommand |
+| `privilege` | `int` | Command privilege level of the current user |
+| `query_id` | `int` | Current Pipeline request ID |
+| `query_uuid` | `str \| None` | Stable identifier for the current Pipeline request |
+
+The SDK calls `context.shift()` before entering the current subcommand. Components do not need to call it directly.
+
+### Context methods
+
+| Method | Return value | Description |
+| --- | --- | --- |
+| `await context.reply(message_chain, quote_origin=False)` | - | Reply in the conversation for the current request |
+| `await context.get_bot_uuid()` | `str` | Get the source bot UUID |
+| `await context.set_query_var(key, value)` | - | Set a variable on the current request |
+| `await context.get_query_var(key)` | `Any` | Get one request variable |
+| `await context.get_query_vars()` | `dict[str, Any]` | Get all request variables |
+| `await context.create_new_conversation()` | `dict[str, Any]` | Clear the current Pipeline conversation so a later request starts a new one |
+| `await context.list_pipeline_knowledge_bases()` | `list[dict[str, Any]]` | List knowledge bases bound to the current Pipeline Local Agent |
+| `await context.retrieve_knowledge(kb_id, query_text, top_k=5, filters=None)` | `list[dict[str, Any]]` | Search a knowledge base bound to the current Pipeline |
+
+These request methods behave the same as their EventListener equivalents. See [Command](/en/plugin/dev/components/command) for registration and return values.
+
+## Tool
+
+Tool has no separate context object. Its `call()` parameters carry the invocation data:
+
+```python
+conversation_type = session.launcher_type.value
+conversation_id = session.launcher_id
+sender_id = session.sender_id
+```
+
+| Parameter | Description |
+| --- | --- |
+| `params` | Arguments generated by the model or caller from the tool JSON Schema |
+| `session` | Current session data |
+| `query_id` | Current Pipeline request ID; pass it unchanged to LangBot APIs that require request context |
+
+Common `Session` properties are:
+
+| Property | Description |
+| --- | --- |
+| `launcher_type` | Conversation type: `person` or `group` |
+| `launcher_id` | Private-chat user or group ID |
+| `sender_id` | Current message sender ID |
+| `bot_uuid` | Current bot UUID |
+| `using_conversation` | Current Pipeline conversation |
+| `conversations` | Pipeline conversations stored in this Session |
+| `use_prompt_name` | Active prompt name |
+| `create_time`, `update_time` | Session creation and update times |
+
+`session` is a data object and has no context methods such as `reply()`. A tool return value goes back to its caller; it is not sent to the chat platform automatically. Use `self.plugin` for LangBot and platform capabilities. If an API requires `session` and `query_id`, pass the values received by the current call.
+
+## Runner
+
+Both custom `run(ctx)` implementations and Runner event handlers receive a `RunnerContext`. It remains valid only while the current invocation is running.
+
+```python
+text = ctx.input.to_text()
+event_type = ctx.event.event_type
+```
+
+### Context data
+
+| Property | Type | Description |
+| --- | --- | --- |
+| `run_id` | `str` | Current run ID |
+| `trigger` | `AgentTrigger` | Trigger type, source, and time |
+| `event` | `AgentEventContext` | Standard event envelope with the event ID, type, source, time, and raw data |
+| `platform_event` | Platform event type | Platform event parsed into its specific event type |
+| `conversation` | `ConversationContext \| None` | Current conversation, thread, bot, and workspace identifiers |
+| `actor` | `ActorContext \| None` | Actor that caused the event |
+| `subject` | `SubjectContext \| None` | Message, group, or other object affected by the event |
+| `input` | `AgentInput` | Current text, structured content, and attachments |
+| `delivery` | `DeliveryContext` | Output target and streaming, editing, and reaction capabilities |
+| `resources` | `AgentResources` | Models, tools, knowledge bases, skills, and storage authorized for this run |
+| `context` | `ContextAccess` | Conversation cursors, inline policy, and available context APIs |
+| `state` | `AgentRunState` | Scope-specific state snapshot loaded at the start of the run |
+| `runtime` | `AgentRuntimeContext` | LangBot version, trace ID, and deadline |
+| `config` | `dict[str, Any]` | Current Agent or plugin-processor configuration |
+| `adapter` | `AdapterContext \| None` | Additional entry-adapter data |
+| `variables` | `dict[str, Any]` | Public request variables for Runner templates |
+| `metadata` | `dict[str, Any]` | Other metadata supplied by the Host |
+
+Prefer the stable properties in this table. `event.data` and `adapter.extra` carry additional data that has not been promoted to stable fields.
+
+### Replies and logs
+
+| Method | Return value | Description |
+| --- | --- | --- |
+| `await ctx.get_bot_uuid()` | `str` | Get the source bot UUID; raises an error when no bot is associated |
+| `await ctx.reply(message_chain, quote_origin=False)` | `Any` | Reply to the current event with a string or `MessageChain` |
+| `ctx.reply_stream()` | Async context manager | Stream full-text updates to one reply |
+| `await ctx.log(text, level="info")` | - | Write to this run's log; levels are `debug`, `info`, `warning`, and `error` |
+
+Each `reply_stream().update(text)` call supplies the complete current text. If the platform does not support streaming, LangBot sends one complete message when the stream finishes. Debug mode only simulates delivery.
+
+### Available tools
+
+| Method | Return value | Description |
+| --- | --- | --- |
+| `await ctx.get_available_tools()` | `list[dict[str, Any]]` | Get context actions, platform APIs, plugin tools, and MCP tools callable in this invocation |
+| `await ctx.call_tool(tool_name, parameters=None)` | `dict[str, Any]` | Call a tool from that list |
+
+Each item returned by `get_available_tools()` contains `name`, `description`, `parameters`, `type`, and `operations`. The result is already filtered by the current Agent or plugin-processor configuration and the event's capabilities. `ctx.call_tool()` cannot call a tool absent from this list.
+
+### Box and attachments
+
+The Runner owns enablement, acquisition, and reuse policy. Use the [LangBot Box APIs](/en/plugin/dev/apis/common#box-sandbox) for resources and these context methods for the current run.
+
+| Method | Returns | Behavior |
+| --- | --- | --- |
+| `await ctx.bind_box(box_id)` | `BoxBinding` | Bind a Workspace Box; returns `box_id`, `outbox`. A run cannot switch Boxes |
+| `await ctx.import_box_attachments(attachment_ids=None)` | `list[BoxFile]` | Import selected input `ref` values, or all inputs; returns sandbox paths |
+| `await ctx.export_box_files()` | `list[BoxFile]` | Export this run's outbox as file handles; sends no message |
+| `await ctx.reply_files(file_ids)` | `Any` | Reply with exported IDs, subject to event reply authorization |
+
+`BoxFile` contains `id`, `name`, `type`, `size`, and optional `path`. Original attachment URLs and content remain available. Files are staged only on explicit import. Output handles belong to this run and cannot be sent twice.
+
+Bind before native sandbox tools or file operations. Runs sharing a Box still have separate attachment directories. `ctx.variables` contains public request variables for computing reuse keys.
+
+`ctx.delivery.automatic_reply` is `True` for Pipeline execution: return files using `RunnerResult.message_completed(ctx.run_id, message, file_ids=[...])`. Agents and plugin processors explicitly call `ctx.reply_files()`.
+
+External runners use the same flow through `AgentRunExternalTools`: `langbot_get_box_status`, `langbot_list_boxes`, `langbot_acquire_box`, `langbot_bind_box`, `langbot_import_box_attachments`, and `langbot_export_box_files`. `langbot_reply_files` is available when the run has event reply permission. All calls retain the current run authorization.
+
+### Prompt, history, and events
+
+| Method | Return value | Availability field |
+| --- | --- | --- |
+| `await ctx.get_prompt()` | `list[dict[str, Any]]` | `prompt_get` |
+| `await ctx.history_page(conversation_id=None, before_cursor=None, after_cursor=None, limit=50, direction="backward", include_attachments=False)` | `HistoryPage` | `history_page` |
+| `await ctx.history_search(query, filters=None, top_k=10)` | `HistorySearchResult` | `history_search` |
+| `await ctx.event_get(event_id)` | `AgentEventRecord` | `event_get` |
+| `await ctx.event_page(conversation_id=None, event_types=None, before_cursor=None, limit=50)` | `EventPage` | `event_page` |
+| `await ctx.steering_pull(mode="all", limit=None)` | `SteeringPullResult` | `steering_pull` |
+
+### State
+
+| Method | Return value | Description |
+| --- | --- | --- |
+| `await ctx.state_get(scope, key)` | `dict[str, Any]` | Read a state value |
+| `await ctx.state_set(scope, key, value)` | `dict[str, Any]` | Store a JSON-serializable state value |
+| `await ctx.state_delete(scope, key)` | `dict[str, Any]` | Delete a state value |
+| `await ctx.state_list(scope, prefix=None, limit=100)` | `dict[str, Any]` | List state keys in a scope |
+
+`scope` can be `conversation`, `actor`, `subject`, or `runner`. These methods require `ctx.context.available_apis.state` to be `true`.
+
+### Run records
+
+| Method | Return value | Availability field |
+| --- | --- | --- |
+| `await ctx.run_get(run_id=None)` | `AgentRun` | `run_get` |
+| `await ctx.run_list(conversation_id=None, statuses=None, before_cursor=None, limit=50)` | `RunPage` | `run_list` |
+| `await ctx.run_events_page(run_id=None, before_cursor=None, after_cursor=None, limit=50, direction="forward")` | `RunEventPage` | `run_events_page` |
+| `await ctx.run_cancel(run_id=None, reason=None)` | `AgentRun` | `run_cancel` |
+| `await ctx.run_append_result(result)` | `AgentRunEvent` | `run_append_result` |
+| `await ctx.run_finalize(run_id=None, status=None, reason=None)` | `AgentRun` | `run_finalize` |
+
+`run_id=None` refers to the current run. The SDK completes a normal event handler automatically. When a custom `run(ctx)` yields `RunnerResult`, do not register the same result again with `run_append_result()`.
+
+### Availability and authorization
+
+Use `ctx.context.available_apis` to check whether prompt, history, event, state, and run-record methods are available. Authorized models, tools, and knowledge bases are in `ctx.resources.models`, `ctx.resources.tools`, and `ctx.resources.knowledge_bases`.
+
+During Runner execution, model, tool, knowledge-base, storage, and platform calls through `self.plugin` are automatically associated with the current run and validated against `ctx.resources`. Do not pass `run_id`. After the handler returns, do not use this context or its run-bound calls from background tasks.
+
+`ctx.api` is the lower-level run-scoped proxy. Normal component code should prefer the `ctx` methods listed on this page and use `self.plugin` for LangBot APIs.
+
+## Data-only contexts
+
+The following components receive task input rather than an EventListener or Runner execution context. Their fields, return values, and complete examples live in the corresponding component guides.
+
+| Component entry point | Main data | Component guide |
+| --- | --- | --- |
+| `KnowledgeEngine.ingest(context: IngestionContext)` | File, target knowledge base, creation settings, and parse result | [KnowledgeEngine](/en/plugin/dev/components/knowledge-engine#document-ingestion) |
+| `KnowledgeEngine.retrieve(context: RetrievalContext)` | Query, target knowledge base, retrieval settings, and filters | [KnowledgeEngine](/en/plugin/dev/components/knowledge-engine#knowledge-retrieval) |
+| `KnowledgeRetriever.retrieve(context: RetrievalContext)` | Query and external knowledge-base settings | [KnowledgeRetriever](/en/plugin/dev/components/knowledge-retriever#retrieval-context) |
+| `Parser.parse(context: ParseContext)` | File content, filename, MIME type, and metadata | [Parser](/en/plugin/dev/components/parser#parse-method) |
+| `Page.handle_api(request: PageRequest)` | Endpoint, HTTP method, body, and headers | [Page](/en/plugin/dev/components/page#pagerequest-fields) |
diff --git a/en/plugin/dev/apis/common.mdx b/en/plugin/dev/apis/common.mdx
index c2d247131..e1196cd9d 100644
--- a/en/plugin/dev/apis/common.mdx
+++ b/en/plugin/dev/apis/common.mdx
@@ -1,116 +1,15 @@
---
-title: "Plugin Common APIs"
+title: "LangBot API"
---
-LangBot provides a series of APIs for plugins to operate various LangBot modules and control message contexts.
-
-## Request API
-
-Operations for the currently processing user request (message). Only available in `EventListener` and `Command` components. Access methods:
-
-- In event handler methods of `EventListener`: Internal methods of `event_context: context.EventContext` object
-- In subcommand handler methods of `Command`: Internal methods of `context: ExecuteContext` object
-
-### Direct Reply Message
-
-Directly reply with a message chain to the session where the current request is located.
-
-For message chain construction methods, please refer to [Message Platform Entities](/en/plugin/dev/apis/messages).
-
-```python
-async def reply(
- self, message_chain: platform_message.MessageChain, quote_origin: bool = False
-):
- """Reply to the message request
-
- Args:
- message_chain (platform.types.MessageChain): LangBot message chain
- quote_origin (bool): Whether to quote the original message
- """
-
-# Usage example
-await event_context.reply(
- platform_message.MessageChain([
- platform_message.Plain(text="Hello, world!"),
- ]),
-)
-```
-
-### Get Bot UUID
-
-Get the UUID of the bot that originated the current request.
-
-```python
-async def get_bot_uuid(self) -> str:
- """Get the bot uuid"""
-
-# Usage example
-bot_uuid = await event_context.get_bot_uuid()
-```
-
-### Set Request Variables
-
-Some information in a single request is stored in request variables. When using external Agent platforms like Dify, [these variables are explicitly passed to Agent platforms](/en/deploy/pipelines/readme.html#request-variables).
-
-```python
-async def set_query_var(self, key: str, value: Any):
- """Set a query variable"""
-
-# Usage example
-await event_context.set_query_var("key", "value")
-```
-
-### Get Request Variables
-
-Get a single request variable.
-
-```python
-async def get_query_var(self, key: str) -> Any:
- """Get a query variable"""
-
-# Usage example
-query_var = await event_context.get_query_var("key")
-```
-
-### Get All Request Variables
-
-```python
-async def get_query_vars(self) -> dict[str, Any]:
- """Get all query variables"""
-
-# Usage example
-query_vars = await event_context.get_query_vars()
-```
-
-### List Pipeline Knowledge Bases
-
-Get the list of knowledge bases configured for the pipeline used by the current request.
-
-```python
-async def list_pipeline_knowledge_bases(self) -> list[dict[str, Any]]:
- """List knowledge bases configured for the current pipeline"""
-
-# Usage example
-knowledge_bases = await event_context.list_pipeline_knowledge_bases()
-
-# Return example
-[
- {
- "uuid": "kb_uuid",
- "name": "Product Docs",
- "description": "Internal product documentation",
- }
-]
-```
-
-Only returns knowledge bases bound in the `Local Agent` configuration of the pipeline corresponding to the current request. If the current pipeline has no knowledge bases configured, or does not use `Local Agent`, an empty list is returned.
+Use `self.plugin` for models, tools, knowledge bases, and storage. For the current event, replies, and conversation state, see [Context APIs](/en/plugin/dev/apis/agent-run).
## LangBot API
These APIs can be called in any plugin component. Access methods:
- In the plugin root directory `main.py`: Internal methods of the `self` object, these APIs are all provided by the plugin class parent class `BasePlugin`.
-- In any plugin component class: Internal methods of the `self.plugin` object.
+- In regular plugin component classes: Internal methods of the `self.plugin` object.
### Get Plugin Configuration
@@ -224,6 +123,9 @@ async def invoke_llm(
messages: list[provider_message.Message],
funcs: list[resource_tool.LLMTool] = [],
extra_args: dict[str, Any] = {},
+ timeout: float | None = None,
+ *,
+ reasoning_level: ReasoningLevel | None = None,
) -> provider_message.Message:
"""Invoke an LLM model"""
@@ -236,6 +138,65 @@ llm_message = await self.plugin.invoke_llm(
)
```
+
+### Reasoning level
+
+All model invocation methods above accept the optional keyword-only `reasoning_level` parameter:
+
+```python
+response = await self.plugin.invoke_llm(
+ llm_model_uuid=model_uuid,
+ messages=[provider_message.Message(role="user", content="Analyze this problem")],
+ reasoning_level="medium",
+)
+```
+
+Values are `provider_default`, `disabled`, `enabled`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. Available levels depend on the model. LangBot validates support and translates the level into provider parameters; unsupported levels raise an error.
+
+- Omitted or `None`: preserves existing model configuration and `extra_args` behavior.
+- `provider_default`: adds no explicit reasoning level.
+- Other levels: apply to this call only. Do not also supply provider-specific reasoning parameters through `extra_args`.
+
+Runners read their own configuration and explicitly pass the appropriate level for each call, including fallback models and tool follow-ups. LangBot does not extract reasoning levels from Runner configuration. Existing plugins require no changes; when an explicit option is used with an unsupported Host, the SDK requests an upgrade before invoking the model.
+
+### Model streaming and usage
+
+| Method | Returns |
+| --- | --- |
+| `invoke_llm_with_usage(llm_model_uuid, messages, funcs=[], extra_args={}, timeout=None, *, reasoning_level=None)` | `LLMInvokeResult` |
+| `invoke_llm_stream(llm_model_uuid, messages, funcs=[], extra_args={}, *, reasoning_level=None)` | Async iterator of `MessageChunk` |
+| `invoke_llm_stream_events(llm_model_uuid, messages, funcs=[], extra_args={}, *, reasoning_level=None)` | Async iterator of `LLMStreamEvent` |
+
+
+Parameters match `invoke_llm`. `timeout` is in seconds; non-streaming calls default to 120 seconds. Use `invoke_llm_with_usage` when token usage is needed:
+
+```python
+from langbot_plugin.api.entities.builtin.provider.message import Message
+
+result = await self.plugin.invoke_llm_with_usage(
+ llm_model_uuid=model_uuid,
+ messages=[Message(role="user", content="Hello!")],
+)
+print(result.message.content)
+if result.usage is not None:
+ print(result.usage.total_tokens)
+```
+
+`usage` may be `None`; `prompt_tokens`, `completion_tokens`, and `total_tokens` may also be absent. Missing values do not mean zero usage. Additional provider fields, such as cache usage, are preserved.
+
+```python
+async for event in self.plugin.invoke_llm_stream_events(
+ llm_model_uuid=model_uuid,
+ messages=[Message(role="user", content="Hello!")],
+):
+ if event.chunk is not None:
+ print(event.chunk.content)
+ if event.usage is not None:
+ print(event.usage.model_dump())
+```
+
+`LLMStreamEvent` has optional `chunk` and `usage`; the final event may contain usage without text. `invoke_llm_stream` yields only message/tool-call chunks. `MessageChunk.content` is the current chunk; `all_content`, when supplied, is accumulated text. Replace with accumulated text and append only deltas; an `is_final` chunk may still carry text. Model streaming does not send platform messages; see [Run Context APIs](/en/plugin/dev/apis/agent-run) for explicit streaming replies.
+
### List Available Parsers
List Parser plugins currently available on the host, optionally filtered by MIME type.
@@ -428,7 +389,7 @@ Use this in conjunction with configuration fields of type [``file or array[file`
## Knowledge Base API
-These APIs are accessible via `self.plugin` in any component, allowing you to list and retrieve from all knowledge bases in the LangBot instance without pipeline restrictions.
+These APIs are accessible via `self.plugin` in regular components, allowing you to list and retrieve from all knowledge bases in the LangBot instance without pipeline restrictions.
### List All Knowledge Bases
@@ -486,7 +447,7 @@ for entry in results:
```
-These APIs do not require a `query_id` and can be used in any component (including Tool components). They can access all knowledge bases without being restricted to the current pipeline's configuration.
+These APIs do not require a `query_id` and can be used in regular components (including Tool components). They can access all knowledge bases without being restricted to the current pipeline's configuration.
## RAG API
@@ -676,3 +637,95 @@ async def get_knowledge_file_stream(self, storage_path: str) -> bytes:
# Usage example
file_bytes = await self.plugin.get_knowledge_file_stream(context.file_object.storage_path)
```
+
+
+## Additional resource APIs
+
+These methods are also called through `self.plugin`.
+
+### Invoke a rerank model
+
+```python
+async def invoke_rerank(
+ self, rerank_model_uuid: str, query: str, documents: list[str],
+ top_k: int | None = None, extra_args: dict[str, Any] | None = None,
+ timeout: float = 60.0,
+) -> list[dict[str, Any]]:
+ ...
+
+scores = await self.plugin.invoke_rerank(
+ rerank_model_uuid=rerank_model_uuid,
+ query="How do I configure a bot?",
+ documents=["Bot configuration guide", "Plugin publishing guide"],
+ top_k=1,
+)
+```
+
+Results usually contain the original document `index` and `relevance_score`.
+
+### Invoke a parser
+
+```python
+async def invoke_parser(
+ self, plugin_author: str, plugin_name: str, storage_path: str,
+ mime_type: str, filename: str, metadata: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ ...
+
+parsers = await self.plugin.list_parsers(mime_type="application/pdf")
+if parsers:
+ parser = parsers[0]
+ parsed = await self.plugin.invoke_parser(
+ plugin_author=parser["plugin_author"],
+ plugin_name=parser["plugin_name"],
+ storage_path=storage_path,
+ mime_type="application/pdf",
+ filename="guide.pdf",
+ )
+```
+
+`storage_path` is a Host storage path, such as `context.file_object.storage_path` during knowledge ingestion, not a plugin-local path. The result dictionary contains `text`, `sections`, and `metadata`.
+
+### List vector records
+
+```python
+async def vector_list(
+ self, collection_id: str, filters: dict[str, Any] | None = None,
+ limit: int = 20, offset: int = 0,
+) -> dict[str, Any]:
+ ...
+
+page = await self.plugin.vector_list(
+ collection_id=collection_id,
+ filters={"file_id": "doc_001"},
+ limit=20,
+ offset=0,
+)
+for item in page["items"]:
+ print(item["id"], item.get("metadata"))
+```
+
+Returns `items` and `total`. Items contain `id`, `document`, and `metadata`; `total` is a best-effort matching count from the storage backend. Filters use the vector API format above.
+
+### List plugins and commands
+
+```python
+manifests = await self.plugin.list_plugins_manifest()
+commands = await self.plugin.list_commands()
+```
+
+Both methods take no arguments and return the Host-provided plugin manifest list and command list respectively.
+
+### Box sandbox
+
+These methods use `self.plugin` and the current Workspace. Runner calls automatically retain invocation authorization.
+
+| Method | Returns | Behavior |
+| --- | --- | --- |
+| `await self.plugin.get_box_status()` | `BoxStatus` | `enabled`, `available`, capacity `limit`, `used`, `remaining`, optional managed `required_reuse_key`, and unavailable `reason` |
+| `await self.plugin.list_boxes()` | `list[BoxSession]` | Box `id` and `status` snapshot (`idle`, `running`, `closing`) |
+| `await self.plugin.acquire_box(reuse_key, options=None)` | `BoxSession` | Reuse the same key or create within capacity; `options` supports `image` |
+
+Unknown capacity fields are `None`. Zero remaining capacity still permits reuse; Runtime checks creation capacity atomically. The Runner computes reuse keys. When `required_reuse_key` is supplied, use it. Equal keys in different Workspaces never share a Box.
+
+Use the [Runner context](/en/plugin/dev/apis/agent-run#runner) to bind the Box before importing attachments or executing tools.
diff --git a/en/plugin/dev/apis/messages.mdx b/en/plugin/dev/apis/messages.mdx
index eadf571c6..e4a084016 100644
--- a/en/plugin/dev/apis/messages.mdx
+++ b/en/plugin/dev/apis/messages.mdx
@@ -58,34 +58,8 @@ When creating components, named parameters must be used. For specific usage, you
## Other Entities
-In addition to the above entities, there are also entities like `Friend` and `Group`. These entities are defined in `langbot_plugin.api.entities.platform.entities`.
+In addition to the above entities, there are also entities like `Friend` and `Group`. These entities are defined in `langbot_plugin.api.entities.builtin.platform.entities`.
-## Accessing Underlying Platform APIs
+## Platform APIs
-
- Not applicable for 4.x.
-
-
-To abstract away platform differences, LangBot provides an abstraction layer above messaging platform APIs, which includes the content described above and various platform adapters in the `pkg/platform/sources` directory. However, due to significant platform differences, the entity and API encapsulation in the abstraction layer is not complete. If your plugin needs to access platform-specific functionality, you can access underlying APIs according to the following instructions.
-
-
- ```python
- # For example in a plugin
- @handler(GroupMessageReceived)
- async def _(self, ctx: EventContext):
- # Get adapter object
- adapter = ctx.query.adapter
- ```
-
-
-| Platform | Adapter | Access Method | Description |
-| ------------------------- | --------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| OneBot v11 | aiocqhttp | adapter.bot | bot object corresponds to [aiocqhttp](https://github.com/nonebot/aiocqhttp) CQHttp object, please refer to aiocqhttp documentation for detailed usage |
-| QQ Official API | qqofficial | adapter.bot | bot object is the SDK object under libs/qq_official_api, for detailed usage please refer to the `send_group_text_msg` method in `QQOfficialClient` in libs/qq_official_api/api.py for HTTP request methods, please refer to QQ Official API documentation for specific interface documentation |
-| WeCom (Enterprise WeChat) | wecom | adapter.bot | Refer to the `send_image` method in `WecomClient` in libs/wecom_api/api.py for HTTP request methods, please refer to WeCom API documentation for specific interface documentation |
-| WeChat Official Account | officialaccount | - | Please refer to WeChat Official Account API documentation for interface documentation, credential information can be obtained from adapter.bot |
-| Lark | lark | adapter.api_client | Lark SDK's API Client object, please refer to [oapi-sdk-python](https://github.com/larksuite/oapi-sdk-python) for details |
-| DingTalk | dingtalk | - | Please refer to DingTalk API documentation, credential information can be obtained from adapter.bot |
-| Discord | discord | adapter.bot | bot object is [Rapptz/discord.py](https://github.com/Rapptz/discord.py) Discord object, please refer to documentation for detailed usage |
-| Telegram | telegram | adapter.bot | bot object is [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) Telegram object, please refer to documentation for detailed usage |
-| Slack | slack | adapter.bot | bot object in libs/slack_api/api.py, please refer to its code and Slack SDK for detailed usage |
\ No newline at end of file
+Use [Platform APIs](/en/plugin/dev/apis/platform) to query users, manage groups, and call platform-specific actions. Runner uses the same `self.plugin` entrypoint; use `ctx.reply()` for the current conversation.
diff --git a/en/plugin/dev/apis/pipeline-events.mdx b/en/plugin/dev/apis/pipeline-events.mdx
index 040615e69..50183854f 100644
--- a/en/plugin/dev/apis/pipeline-events.mdx
+++ b/en/plugin/dev/apis/pipeline-events.mdx
@@ -248,42 +248,13 @@ class PromptPreProcessing(BaseEventModel):
"""Existing message records for this conversation, can be modified, langbot_plugin.api.entities.builtin.provider.message.Message or langbot_plugin.api.entities.builtin.provider.message.MessageChunk type"""
```
-## Event Context APIs
+## EventListener context
-```python
-...
- @self.handler(events.PersonMessageReceived)
- async def handler(event_context: context.EventContext):
- ...
-```
+Event handlers read the current event through `event_context.event`. See [Context APIs](/en/plugin/dev/apis/agent-run#eventlistener) for the complete `EventContext` properties and methods.
-Event processing methods will be passed the `EventContext` object, which contains event context information, and the object has both [Request API](/en/plugin/dev/apis/common) and event context specific APIs. The following is a list of event context specific APIs:
+### Prevent default behavior
-### Get Original Event Attributes
-
-`event_context.event` attribute is the original event object, which can get the attributes of the original event. The type is the type of the event being listened to.
-
-For example, when the event being listened to is `GroupMessageReceived`:
-
-```python
-event = event_context.event
-print(event.launcher_type) # Launcher object type (group)
-print(event.launcher_id) # Group ID
-print(event.sender_id) # Sender account ID
-print(event.message_chain) # Message chain
-```
-
-### Prevent Default Behavior
-
-```python
-def prevent_default(self):
- """Prevent default behavior"""
-
-# Usage example
-event_context.prevent_default()
-```
-
-Calling this method will prevent the default subsequent behavior of this event, and the pipeline will end directly.
+Calling `event_context.prevent_default()` stops the default handling of the current event. Only these events support it:
Only the following events can prevent default behavior:
@@ -297,14 +268,4 @@ Only the following events can prevent default behavior:
- NormalMessageResponded
-### Prevent Postorder Execution
-
-```python
-def prevent_postorder(self):
- """Prevent postorder execution"""
-
-# Usage example
-event_context.prevent_postorder()
-```
-
-Calling this method will prevent the subsequent plugins from executing this time.
\ No newline at end of file
+Call `event_context.prevent_postorder()` to stop later plugins from handling the current event.
diff --git a/en/plugin/dev/apis/platform.mdx b/en/plugin/dev/apis/platform.mdx
new file mode 100644
index 000000000..40219c03f
--- /dev/null
+++ b/en/plugin/dev/apis/platform.mdx
@@ -0,0 +1,78 @@
+---
+title: "Platform APIs"
+---
+
+Call platform adapter APIs using a bot UUID. Continue using `self.plugin.send_message()` for message chains; use the API below for groups, users, and other platform features.
+
+## Call a platform API
+
+```python
+async def call_platform_api(
+ self,
+ bot_uuid: str,
+ action: str,
+ params: dict[str, Any] | None = None,
+) -> Any:
+ ...
+```
+
+Call through `self.plugin` in Command, EventListener, Tool, and other regular components. Results are serialized dictionaries, lists, scalars, or `None`, depending on the action, rather than platform SDK objects.
+
+```python
+group = await self.plugin.call_platform_api(
+ bot_uuid=bot_uuid,
+ action="get_group_info",
+ params={"group_id": "123456"},
+)
+```
+
+`bot_uuid` comes from `await self.plugin.get_bots()` or `await event_context.get_bot_uuid()` in a Pipeline event. Use platform IDs from real events or platform configuration.
+
+### Common actions
+
+| `action` | `params` |
+| --- | --- |
+| `get_user_info` | `user_id` |
+| `get_friend_list` | `{}` |
+| `get_group_info` | `group_id` |
+| `get_group_list` | `{}` |
+| `get_group_member_list` | `group_id` |
+| `get_group_member_info` | `group_id`, `user_id` |
+| `get_message` / `delete_message` | `chat_type`, `chat_id`, `message_id` |
+| `set_group_name` | `group_id`, `name` |
+| `mute_member` | `group_id`, `user_id`, `duration` |
+| `unmute_member` / `kick_member` | `group_id`, `user_id` |
+| `leave_group` | `group_id` |
+| `approve_friend_request` | `request_id`, `approve`, `remark` |
+| `approve_group_invite` | `request_id`, `approve` |
+| `get_file_url` | `file_id` |
+
+
+Supported actions vary by platform and may require bot administrator permissions. `duration` is in seconds; the meaning of `0` is platform-specific, not a universal permanent mute. Queries may depend on platform caches. Calls raise an exception if the bot is not running, the action is unsupported, or parameters are invalid.
+
+## Platform-specific APIs
+
+Set the outer `action` to `call_platform_api` and put the platform action and parameters inside `params`. For example, read the bot account using OneBot Omni:
+
+```python
+account = await self.plugin.call_platform_api(
+ bot_uuid=bot_uuid,
+ action="call_platform_api",
+ params={"action": "get_login_info", "params": {}},
+)
+```
+
+Only actions declared by the adapter are allowed; this is not unrestricted pass-through to every platform endpoint. Plugins call the Host through the runtime, rather than accessing `ctx.query.adapter` or `adapter.bot`.
+
+## Runner
+
+During Runner execution, model, tool, knowledge-base, storage, and platform calls through `self.plugin` automatically use the current run and its authorization. Do not pass `run_id`. Concurrent runs remain isolated. Do not continue these run-bound calls after the handler returns.
+
+Use `await ctx.get_bot_uuid()` for the current bot. Platform calls must match the tool grants for this invocation. Event-target grants cannot access another target. Ungranted actions, including platform-specific actions, are rejected. These calls also use Mock delivery in debug mode.
+
+```python
+tools = await ctx.get_available_tools()
+if any(tool["name"] == "event_get_actor" for tool in tools):
+ actor = await ctx.call_tool("event_get_actor")
+ await ctx.log(str(actor))
+```
diff --git a/en/plugin/dev/basic-info.mdx b/en/plugin/dev/basic-info.mdx
index e51fc5a3b..baf698641 100644
--- a/en/plugin/dev/basic-info.mdx
+++ b/en/plugin/dev/basic-info.mdx
@@ -30,6 +30,8 @@ execution:
attr: HelloPlugin # Do not modify
```
+To certify your plugin and let several installations share one runtime, also declare `sharedRuntime` and `componentModel` under `execution`; see [Certified plugins and shared runtime](/en/plugin/certified-plugins).
+
Plugin multilingual support follows the [RFC 4646](https://datatracker.ietf.org/doc/html/rfc4646) standard. Currently supported languages include:
- `en_US` English (required)
@@ -159,6 +161,7 @@ Dropdown menu. Requires configuring `options`, which represents the dropdown men
```yaml
- name: mode
type: select
+ allow_custom: true
options: # Dropdown options, supports multilingual.
- name: mode1 # Value, displayed as secondary text in the dropdown.
label: # Display name, supports multilingual.
@@ -171,6 +174,8 @@ Dropdown menu. Requires configuring `options`, which represents the dropdown men
...
```
+Set `allow_custom: true` to add a Custom option alongside the presets. A text input appears only when Custom is selected. The saved value is the selected `name` or the custom string, never a Custom marker. This also applies to a runner’s `spec.config`.
+
### type: prompt-editor
Prompt editor. Displays a prompt editor from the pipeline configuration page, with the final result represented in OpenAI's `messages` format.
diff --git a/en/plugin/dev/components/add.mdx b/en/plugin/dev/components/add.mdx
index 4bc82626a..869d1b5f4 100644
--- a/en/plugin/dev/components/add.mdx
+++ b/en/plugin/dev/components/add.mdx
@@ -4,6 +4,8 @@ title: "Adding Components"
Plugins consist of one or more components that provide different functionalities to LangBot. Currently supported component types include:
+- Plugin processor (Runner): handles platform events in plugin code after an instance is created and bound. See the [tutorial](/en/plugin/dev/components/runner).
+- Agent runner (Runner): provides execution logic selected in an Agent configuration; it is distinct from a standalone plugin processor.
- Event Listeners (EventListener): Listen to events during pipeline execution and modify context or pipelines.
- Commands (Command): Triggered by user command messages starting with `!` (or other configured prefixes).
- Tools (Tool): Called by LLMs during execution of LangBot's built-in Local Agent.
@@ -70,6 +72,7 @@ To remove components, you can delete the corresponding information manually.
For detailed usage of each component, please refer to:
+- [Runner](./runner)
- [EventListener](./event-listener)
- [Command](./command)
- [Tool](./tool)
diff --git a/en/plugin/dev/components/command.mdx b/en/plugin/dev/components/command.mdx
index c9fd77d87..f46f5dcfc 100644
--- a/en/plugin/dev/components/command.mdx
+++ b/en/plugin/dev/components/command.mdx
@@ -207,8 +207,20 @@ Command processing supports returning multiple messages, so please use `yield` s
For specific return values, please refer to the definition of `CommandReturn`: [langbot_plugin.api.entities.builtin.command.context.CommandReturn](https://github.com/langbot-app/langbot-plugin-sdk/tree/main/src/langbot_plugin/api/entities/builtin/command/context.py)
+## One installation, many usages
+
+A single command is triggered **concurrently by several conversations**:
+
+- Read config per call; never read it in `initialize()` and cache it on the instance.
+- Never leave command arguments, return values or conversation data on instance fields or module-level variables; `ExecuteContext` is valid only for the current execution.
+- Concurrent triggering means instance fields get interleaved writes — do not use them to hold "current conversation" style state. Data that must survive calls belongs in Host storage, keyed with the conversation identity.
+- If you keep per-installation process-local caches, release them in `on_installation_revoked(binding)`.
+
+See [Certified plugins and shared runtime](/en/plugin/certified-plugins) for the full specification.
+
## What's Next
You have learned the basic information about command registration and command execution. Next, you can:
-- Check out [Plugin Common APIs](/en/plugin/dev/apis/common)
+- See the complete [`ExecuteContext` API reference](/en/plugin/dev/apis/agent-run#command).
+- Check out [Plugin Common APIs](/en/plugin/dev/apis/common).
diff --git a/en/plugin/dev/components/event-listener.mdx b/en/plugin/dev/components/event-listener.mdx
index e36937c42..9fe045ca0 100644
--- a/en/plugin/dev/components/event-listener.mdx
+++ b/en/plugin/dev/components/event-listener.mdx
@@ -2,6 +2,10 @@
title: "Component: Event Listener"
---
+
+This component extends Pipeline processing steps. To handle Bot events such as incoming messages and new group members, see [Event Processor](/en/plugin/dev/components/runner).
+
+
LangBot's pipeline generates events during runtime for plugins to hook into and process. Each plugin can only have one event listener (components/event_listener/default.yaml), but can register any number of events within it.
## Adding Event Listener Component
@@ -95,9 +99,21 @@ class EventContext(pydantic.BaseModel):
Event registration is implemented through the `@self.handler` decorator, with the decorator parameter being the event type. All monitorable events are defined in `langbot_plugin.api.entities.builtin.events`.
+## One installation, many usages
+
+The same event listener is called **concurrently by several Workspaces and several events**:
+
+- Never accumulate state per event on the instance (counters, last event, conversation maps); concurrent callbacks will overwrite each other.
+- A callback takes its data only from the current `EventContext`; never reuse a context or its fields across calls.
+- Data that must survive calls belongs in Host storage, keyed with the installation identity and the event-related identity (Workspace, conversation, and so on).
+- If you keep per-installation process-local state, release it in `on_installation_revoked(binding)`.
+
+See [Certified plugins and shared runtime](/en/plugin/certified-plugins) for the full specification.
+
## What's Next
You have learned the basic information about event listeners. Next, you can:
-- Check the `List of Monitorable Events` and `Event Context APIs` in the documentation [Pipeline Events](/en/plugin/dev/apis/pipeline-events)
+- See [Pipeline Events](/en/plugin/dev/apis/pipeline-events) for event types and fields.
+- See the complete [`EventContext` API reference](/en/plugin/dev/apis/agent-run#eventlistener).
- Check out [Plugin Common APIs](/en/plugin/dev/apis/common)
diff --git a/en/plugin/dev/components/knowledge-engine.mdx b/en/plugin/dev/components/knowledge-engine.mdx
index 8c8ccd008..901ec2b7b 100644
--- a/en/plugin/dev/components/knowledge-engine.mdx
+++ b/en/plugin/dev/components/knowledge-engine.mdx
@@ -467,6 +467,18 @@ async def get_knowledge_file_stream(self, storage_path: str) -> bytes:
file_bytes = await self.plugin.get_knowledge_file_stream(context.file_object.storage_path)
```
+## One installation, many usages
+
+A single knowledge engine is used by **several knowledge bases** at once, and the platform storage key `(instance, Workspace, owner type, plugin, key)` carries no knowledge-base dimension:
+
+- Persistence and cache keys must include the **knowledge-base ID**, otherwise vectors and document state of different knowledge bases overwrite each other.
+- Locks and reconciliation state must be keyed by `(installation, knowledge base)`; **a failure while ingesting one knowledge base must not block ingestion or retrieval of the others under the same installation**.
+- Only a dispatched mutation with an unknown outcome should enter a state that needs manual reconciliation; deterministic validation errors and pre-dispatch cancellation must not affect later calls.
+- Telemetry and diagnostic failures must not affect the work: never let a log or report failure interrupt ingestion or retrieval, and never write tenant data into a process-wide singleton.
+- If you keep per-installation process-local caches or state, release them in `on_installation_revoked(binding)`.
+
+See [Certified plugins and shared runtime](/en/plugin/certified-plugins) for the full specification.
+
## Testing the Knowledge Engine
After creation, execute the command `lbp run` in the plugin directory to start debugging. Then in LangBot:
diff --git a/en/plugin/dev/components/knowledge-retriever.mdx b/en/plugin/dev/components/knowledge-retriever.mdx
index 23e96fb30..4f0c598d6 100644
--- a/en/plugin/dev/components/knowledge-retriever.mdx
+++ b/en/plugin/dev/components/knowledge-retriever.mdx
@@ -253,6 +253,18 @@ using_rerank = self.config.get('using_rerank', False)
search_mode = self.config.get('search_mode', 'embedding')
```
+## One installation, many usages
+
+A single knowledge retriever is used by **several knowledge bases**, and the platform storage key `(instance, Workspace, owner type, plugin, key)` carries no knowledge-base dimension:
+
+- Any cache, index or intermediate result used for retrieval must be isolated by **knowledge-base ID**, with the ID in the key; never keep it on the instance.
+- Key retrieval locks and reconciliation state by `(installation, knowledge base)`; a retrieval failure in one knowledge base must not make retrieval unavailable for the others under the same installation.
+- `RetrievalContext` is valid only for a single retrieval; never store it or its results on instance fields for reuse across calls.
+- Knowledge-base data that must survive calls belongs in Host storage, with a key that includes both the installation identity and the knowledge-base ID.
+- If you cache connections, indexes or any other process-local state per installation, release them in `on_installation_revoked(binding)`.
+
+See [Certified plugins and shared runtime](/en/plugin/certified-plugins) for the full specification.
+
## Testing the Retriever
After creation, execute the command `lbp run` in the plugin directory to start debugging. Then in LangBot:
diff --git a/en/plugin/dev/components/page.mdx b/en/plugin/dev/components/page.mdx
index af4b178b1..1b1349842 100644
--- a/en/plugin/dev/components/page.mdx
+++ b/en/plugin/dev/components/page.mdx
@@ -79,10 +79,9 @@ class DashboardPage(Page):
# request.body: Request body (parsed JSON, or None)
if request.endpoint == '/stats' and request.method == 'GET':
- # Access shared plugin state via self.plugin
- return PageResponse.ok({
- 'total': len(self.plugin.entries),
- })
+ # Read installation-scoped data from a Host storage API.
+ entries = await self.load_entries_for_current_installation()
+ return PageResponse.ok({'total': len(entries)})
return PageResponse.fail(f'Unknown endpoint: {request.endpoint}')
```
@@ -200,24 +199,22 @@ from langbot_plugin.api.definition.components.page import Page, PageRequest, Pag
class ManagerPage(Page):
async def handle_api(self, request: PageRequest) -> PageResponse:
- plugin = self.plugin
-
if request.endpoint == '/entries' and request.method == 'GET':
- return PageResponse.ok({'entries': plugin.entries})
+ entries = await self.load_entries_for_current_installation()
+ return PageResponse.ok({'entries': entries})
if request.endpoint == '/entries' and request.method == 'POST':
question = (request.body or {}).get('question', '').strip()
answer = (request.body or {}).get('answer', '').strip()
if not question or not answer:
return PageResponse.fail('question and answer are required')
- entry = plugin.add_entry(question, answer)
- await plugin.persist()
+ entry = {'question': question, 'answer': answer}
+ await self.save_entry_for_current_installation(entry)
return PageResponse.ok({'entry': entry})
if request.endpoint == '/entries' and request.method == 'DELETE':
entry_id = (request.body or {}).get('id', '')
- if plugin.delete_entry(entry_id):
- await plugin.persist()
+ if await self.delete_entry_for_current_installation(entry_id):
return PageResponse.ok({'deleted': entry_id})
return PageResponse.fail('entry not found')
@@ -239,9 +236,20 @@ await langbot.api('/entries', { id: '...' }, 'DELETE');
Full example code is available at [FAQManager plugin](https://github.com/langbot-app/langbot-plugin-demo/tree/main/FAQManager).
+## One installation, many usages
+
+The same Page is called by **many page requests** (different requests and different Workspaces under one installation):
+
+- Never leave the previous request's data on the instance; take the endpoint, method, body and current installation information from the current `PageRequest` / binding every time.
+- Page data always lives in installation-scoped Host storage; `self.plugin` is a process-level shared object and must not hold tenant data.
+- Requests from different Workspaces share the same Page instance, so instance fields get interleaved; data that must survive requests belongs in Host storage.
+- If you keep per-installation process-local caches, release them in `on_installation_revoked(binding)`.
+
+See [Certified plugins and shared runtime](/en/plugin/certified-plugins) for the full specification.
+
## Notes
- Pages run inside a `sandbox="allow-scripts allow-forms"` iframe and cannot open popups or navigate the parent page
-- Access shared plugin state via `self.plugin` — Page and Tool components can share data
+- Certified Pages must keep tenant data in installation-scoped Host storage. `self.plugin` is process-wide and must not hold tenant entries. The storage helper names above are illustrative; use the current SDK Host storage APIs available to your component.
- Use `PageResponse.ok()` and `PageResponse.fail()` to construct responses for consistent formatting
- The Page SDK `