Files
WeKnora/pluginsdk
lyingbug ff49f9491d feat(plugin): Host API with a key-value store, and document parsers from plugins (#3721)
* feat(plugin): Host API with plugin tokens and a key-value store
* feat(plugin): document parsers from code plugins
* docs(pluginsdk): parsers and the Host API
2026-09-26 14:45:39 +08:00
..

WeKnora plugin SDK (Go)

Build code plugins for WeKnora: web search providers, data source connectors and document parsers that run as their own process. The module has no dependencies.

Package What it is
pluginapi The extension protocol v1: envelope, errors, stream events, messages. openapi.yaml describes it for other languages.
pluginsdk Write a plugin: register contributions, then Serve.
client Call a plugin, as WeKnora does.
conformance, cmd/weknora-plugin-conformance Check that a plugin speaks the protocol.

A connector in 30 lines

package main

import (
	"context"
	"log"

	"github.com/Tencent/WeKnora/pluginsdk"
	"github.com/Tencent/WeKnora/pluginsdk/pluginapi"
)

type notes struct{}

func (notes) Validate(ctx context.Context, call *pluginsdk.Call, cfg pluginsdk.ConnectorConfig) error {
	if cfg.Credentials["token"] == nil {
		return pluginapi.InvalidConfig("token is required", map[string]string{"credentials.token": "required"})
	}
	return nil
}

func (notes) ListResources(ctx context.Context, call *pluginsdk.Call, cfg pluginsdk.ConnectorConfig, parent string) ([]pluginapi.Resource, error) {
	return []pluginapi.Resource{{ExternalID: "inbox", Name: "Inbox"}}, nil
}

func (notes) Fetch(ctx context.Context, call *pluginsdk.Call, cfg pluginsdk.ConnectorConfig, in pluginapi.FetchInput, s *pluginsdk.Stream) (*pluginapi.Cursor, error) {
	if err := s.Item(pluginapi.FetchedItem{ExternalID: "n1", Title: "First note", Content: []byte("# Hello")}); err != nil {
		return nil, err
	}
	return &pluginapi.Cursor{State: map[string]any{"after": "n1"}}, nil
}

func main() {
	p := pluginsdk.New(pluginsdk.Info{ID: "acme.notes", Version: "1.0.0"})
	p.Connector("notes", notes{})
	log.Fatal(p.Serve())
}

What the SDK does for you:

  • Handshake. Started by WeKnora's plugin host, the plugin listens on the socket it is given and accepts only the host's token.
  • Remote mode. Run on its own, it listens on WEKNORA_PLUGIN_ADDR and verifies signed requests with WEKNORA_PLUGIN_SECRET.
  • Per call. Each call's deadline reaches your context. Returned pluginapi errors keep their code; anything else, and any panic, becomes internal.

Every call carries the tenant and the configuration, secrets already decrypted, in three separate scopes:

  • call.Config.System: set by the system administrator.
  • call.Config.Tenant: set per workspace.
  • the instance: cfg, or call.DecodeInstance.

Keep plugins stateless and never cache a tenant's credentials across calls.

Parsers

p.Parser(id, ...) turns a document (in.Content, base64 on the wire) into Markdown. WeKnora chunks the text and stores the images the Markdown references: return them in Images with an OriginalRef matching the ![](...) target. Declare the extensions it handles in plugin.yaml (fileTypes: [srt, vtt]). Return a retryable error (unavailable, rate_limited) to have the document retried later; any other error fails it for good.

Calling back into WeKnora (Host API)

A plugin that declares permissions.hostApi gets a short-lived token with each call; call.Host() returns a client for it, or nil without a grant. Scopes:

  • kv: a key-value store of the plugin, separate per workspace, so the plugin needs no database.
    • KVGet, KVPut (optional TTL), KVDelete and KVList.
    • Keys up to 256 bytes, JSON values up to 64 KB, 10,000 keys per workspace.

The token is valid for a few minutes: use call.Host() within the call and don't keep it.

Packaging

A package is a zip (.wkp) with plugin.yaml at its root:

schemaVersion: 1
id: acme.notes
version: 1.0.0
apiVersion: weknora.plugin/v1
name: { en-US: ACME Notes }
publisher: { id: acme }
runtime: { type: host, kind: binary, entry: "bin/{os}-{arch}/notes" }
permissions:
  egress: [api.acme.example]      # hosts the plugin may reach; "*" = any public host
contributes:
  connectors:
    - id: notes
      name: { en-US: ACME Notes }
      instanceSchema: schemas/notes.yaml   # fields with x-group: settings are settings, the rest credentials

A few rules the manifest and host enforce:

  • Binaries. Build one per platform into bin/<os>-<arch>/; WeKnora runs the build for its own OS and architecture.
  • Environment. The process gets no environment from WeKnora beyond the WEKNORA_PLUGIN_* variables.
  • Outbound traffic. It goes through the host's egress proxy, which forwards only to the hosts in permissions.egress and never to private addresses.
  • IDs. Contribution IDs are qualified with the plugin ID (acme.notes/notes); for connectors and web search that must stay within 50 characters.

See examples/plugins/rss (a connector) and examples/plugins/subtitles (a parser using the Host API) for complete plugins with their package.sh.

Testing

go run github.com/Tencent/WeKnora/pluginsdk/cmd/weknora-plugin-conformance -url http://localhost:8080 -secret $SECRET

In Go tests, run conformance.Run against httptest.NewServer(p.Handler()).

Installing

In WeKnora: System administration → Plugin management → Install plugin. Upload the .wkp or give its URL, review what it adds and reaches, and install. Each workspace then enables it under Settings → Plugins.