Skip to content

[Feature] Adopt the OpenCode v2 (Effect-TS) plugin architecture as OpenCloud's plugin/workflow system #3465

Description

@suse-coder

Is your feature request related to a problem? Please describe.

Today there is no simple, safe and reliable way for a company (or a single power user) to extend OpenCloud with small pieces of custom logic. Typical things people want to do are:

  • "When somebody adds a task/checkbox to a markdown document, create a ticket in our issue tracker."
  • "When a list item is added to TODO.md in a shared space, notify a Teams/Slack/Matrix channel."
  • "When a file lands in /Incoming/Invoices, call our ERP API and move the file."
  • "Expose a small tool/command inside OpenCloud that talks to an internal service."

Right now every one of these requires either a full external integration (webhooks + a separately hosted service), deep changes to the Go backend, or a frontend-only web extension that cannot react to server-side events reliably. There is no first-class, sandboxed, scriptable "plugin" concept that is:

  1. Easy – a small index.ts in a folder, not a new microservice.
  2. Secure – plugin code runs isolated, with an explicit, narrow API surface and no access to internal services.
  3. Reliable – lifecycle, cleanup, retries, timeouts, background jobs and error handling are handled by the runtime, not by every plugin author re-inventing them.
  4. Small & composable – a plugin (or a whole plugin host) should be able to run as a tiny, stateless-looking unit whose durable state lives in object/SQLite storage, so it can be evicted, moved, scaled and recomposed without losing anything.

Every company wants to write a few of these small plugins quickly, keep them in their repo, and know they won't take down or compromise the server.

Describe the solution you'd like

OpenCloud should adopt (or closely mirror) the OpenCode v2 plugin architecturehttps://opencode.ai/v2/docs (open source) – and in particular its Effect-TS variant: https://opencode.ai/v2/docs/build/plugins/effect/

Concretely:

1. Plugin packaging & discovery, same as OpenCode

  • Load plugins automatically from a well-known directory (e.g. .opencloud/plugins/<name>/index.ts per space/instance) and from published packages via config:
// opencloud.jsonc
{
  "plugins": [
    "opencloud-acme-plugin",
    "@acme/opencloud-invoices@1.2.0",
    "./plugins/local-workflow",
    { "package": "@acme/opencloud-tasks", "options": { "tracker": "jira", "strict": true } }
  ]
}
  • Options are passed to the plugin via ctx.options, exactly like OpenCode.

2. Effect-native plugin definition

A plugin is a single default export; its lifetime is a Scope, so registrations, background fibers and finalizers are cleaned up together on reload/unload:

import { Plugin } from "@opencloud/plugin/effect"
import { Effect, Stream, Schedule } from "effect"

export default Plugin.define({
  id: "acme.tasks",
  effect: (ctx) =>
    Effect.gen(function* () {
      // React to server-side events (files, markdown edits, shares, spaces...)
      yield* ctx.event.subscribe().pipe(
        Stream.filter((e) => e.type === "markdown.task.added"),
        Stream.runForEach((e) => createTicket(e)),
        Effect.forkScoped,
      )

      // Background job, automatically interrupted on unload
      yield* Effect.repeat(syncTracker(), { schedule: Schedule.spaced("5 minutes") })
        .pipe(Effect.forkScoped)

      yield* Effect.addFinalizer(() => Effect.logInfo("acme.tasks unloaded"))
    }),
})

3. A narrow, typed context – no access to private core services

Mirror OpenCode's Context with OpenCloud domains, e.g.:

Domain Purpose
ctx.event Stream of OpenCloud events (file created/updated/deleted, markdown task/list item added, share created, space changes, user events)
ctx.file / ctx.space read/write files, metadata, tags, move/copy, list spaces
ctx.markdown structured access to markdown docs (tasks, list items, headings, frontmatter)
ctx.share / ctx.user / ctx.group read & manage shares, resolve users/groups
ctx.notification send notifications (in-app, mail)
ctx.command register commands/actions that show up in the web UI (add-only, like OpenCode's CommandEditor)
ctx.tool register typed tools (Effect Schema input/output) usable by OpenCloud automations and AI features, incl. codemode: true
ctx.integration OAuth/key connections to external systems (Jira, GitHub, ERP…), with authorize/refresh as Effects
ctx.storage durable JSON KV scoped to the plugin ID, with prefix scan – backed by pluggable storage (see §6)
ctx.hook intercept operations before/after (file.write.before, share.create.before, upload.after…) with the ability to Effect.fail to veto
ctx.options / ctx.app plugin options and instance info

Hooks and transforms should follow OpenCode's semantics: run in plugin order, later hooks see earlier changes, registrations return a Registration with dispose, and everything is released when the plugin scope closes.

4. Secure execution

  • Run plugins in an isolated JS/TS runtime (Bun/Node worker, workerd, or a sidecar "plugin host" process) that only talks to OpenCloud through the typed plugin API – never directly to the database, storage backend or internal Go services.
  • Support OpenCode's code mode pattern (codemode: true): tool/plugin code executed through a restricted DSL/sandbox with an explicit allow-list of capabilities, so admins can permit "small scripts" without granting full runtime access.
  • Per-plugin permissions declared up front (which events, which spaces, which outbound hosts), enforced by the host.

5. Reliability via Effect-TS

Using Effect gives every plugin, for free:

  • typed errors instead of thrown exceptions,
  • structured concurrency (forkScoped) and guaranteed cleanup (addFinalizer),
  • built-in retries, timeouts and schedules,
  • backpressured Streams for events,
  • a runtime that can supervise, restart and hot-reload plugins without restarting OpenCloud.

6. Runtime profiles with durable state on object/SQLite storage – small and composable

OpenCode ships different host profiles for the same plugin API. Besides the local Bun/Node profile, @opencode-ai/sdk/workerd runs the whole host inside a Cloudflare Durable Object: it uses the object's SQLite storage, persists durable events so the host can be evicted and recovered, and swaps out unavailable local filesystem/process services for storage-backed equivalents:

import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd"
import myPlugin from "./my-plugin"

export class OpenCodeDO {
  private readonly opencode: Promise<OpenCodeWorkerd.Interface>

  constructor(state: DurableObjectState) {
    // One host per DO instance, not per request; blockConcurrencyWhile
    // keeps events out until ready and resets the object if init fails.
    this.opencode = state.blockConcurrencyWhile(() =>
      OpenCodeWorkerd.create({
        storage: state.storage,          // durable state lives here
        config: { default_agent: "build" },
        plugins: [myPlugin],
      }),
    )
  }

  async fetch() {
    const opencode = await this.opencode
    return Response.json(await opencode.health.get())
  }
}

OpenCloud should offer the same shape:

  • Storage-backed host state. ctx.storage, plugin registrations, pending hooks and the durable event log are written to a pluggable storage adapter – Durable Object SQLite, an S3/object-storage bucket, Postgres, or OpenCloud's own decomposedfs/posixfs – instead of local disk or in-memory state.
  • Eviction-safe by design. Because the event stream is persisted, a plugin host can be evicted, restarted, moved to another node or scaled to zero and pick up exactly where it left off. No lost "task added" events during redeploys.
  • Tiny units. A host + a handful of plugins becomes a very small, stateless-looking unit (a Worker/DO, a container, a sidecar) whose entire state is external. That makes it cheap to run one host per space, per team or per tenant, which is a natural isolation and billing boundary for OpenCloud.
  • Composable. Hosts are addressable objects; they can be composed – one per space forwarding to a per-tenant host, a central "integrations" host that others call via ctx.rpc – without any shared mutable state, exactly like Durable Objects compose.
  • Same plugin, any profile. The plugin code (Plugin.define({...})) does not change between the local profile, a Kubernetes sidecar and a Durable Object; only the host profile and storage adapter differ. Plugin authors never think about persistence or eviction.
  • Same lifecycle guarantee. blockConcurrencyWhile-style startup gating should be built into the host: no event is delivered until all plugins have loaded, and a failed initialization resets the unit cleanly instead of leaving a half-loaded host serving traffic.

7. Why this matters for OpenCloud specifically

The killer use case is the markdown document as a lightweight workflow surface: a team keeps a tasks.md/board.md in a space, and a 30-line plugin turns "someone ticked a checkbox / added a list item" into an API call, a process trigger, a notification, or a file operation. Combined with §6, that 30-line plugin can run as a tiny per-space unit with its state in object storage – no server to babysit, nothing lost on restart. This is exactly what OpenCode's architecture already makes trivial for agentic workflows, and OpenCloud could be the first file platform to offer it natively.

Reference implementations to look at: https://github.com/azatakmyradov/opencode-plugins/tree/main

Describe alternatives you've considered

  • Webhooks + external service (n8n, Zapier, custom microservice): works, but every small automation becomes its own deployment with auth, retries, hosting and state to manage. No sandboxing inside OpenCloud, no UI integration, no shared storage/config, and delivery is fire-and-forget – events are lost if the receiver is down.
  • Frontend-only web extensions: good for UI, but cannot reliably react to server-side events (uploads via WebDAV/sync clients, other users' edits, scheduled jobs).
  • Native Go plugins / forking services: high barrier to entry, must be compiled against a specific version, no isolation – a bug crashes the server.
  • WASM plugin system (e.g. Extism): strong sandbox, but poor DX for the target audience (people who want to write a few lines of TypeScript quickly), and no established ecosystem/patterns for lifecycle, hooks, streams or durable state.
  • MCP servers only: great for AI tool exposure, but MCP has no concept of event subscriptions, hooks, transforms, plugin lifecycle or persistence. It should be one domain the plugin API can manage (as ctx.mcp in OpenCode), not the whole story.
  • Stateful long-running plugin daemon with local disk: simpler at first, but not evictable, not horizontally scalable, and turns every plugin host into a pet. Storage-backed hosts (§6) avoid this.
  • Inventing a bespoke OpenCloud plugin API from scratch: possible, but OpenCode v2 already solved the hard parts (scoped lifecycle, ordered hooks/transforms, typed tools, integrations/OAuth, storage, secure code mode, multiple host profiles incl. Durable Objects) and is open source. Reusing the design keeps plugins portable and lets the two communities share patterns and even packages.

Additional context

Suggested minimal first milestone:

  1. Plugin host (Bun/Node sidecar) that loads .opencloud/plugins/* and configured packages, with Plugin.define + Scope lifecycle and startup gating (no events before all plugins are loaded).
  2. ctx.event.subscribe() bridged from OpenCloud's existing event bus (NATS events for file/share/space changes) plus new markdown-level events (task.added, task.completed, list.item.added), with a persisted event log so hosts can be restarted without loss.
  3. ctx.file, ctx.storage, ctx.notification, ctx.options – with ctx.storage behind a storage-adapter interface (SQLite/Postgres/object storage first).
  4. ctx.hook for file.write.before/after and share.create.before (vetoable).
  5. ctx.command / ctx.tool so plugins can surface actions in the web UI.
  6. A second host profile (workerd/Durable Object or a minimal container) proving the same plugin runs unchanged with all state in external storage – one host per space.

Everything else (integrations/OAuth, MCP management, code mode, transforms, host-to-host RPC) can follow the OpenCode API shape incrementally.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions