Zum Inhalt springen

Pakete

Für Entwickler. Wähle im Entwicklerhandbuch zwischen API-Anbindung, App-Veröffentlichung und Projektarbeit.

OpenBot ist ein pnpm-Workspace. Jedes Paket hat eine Aufgabe. openbot setzt die Teile zum Server zusammen. Die anderen Bibliotheken kannst du auch einzeln nutzen.

Alle Pakete heißen @openbot-build/<name>, haben die Version 0.1.0 und nutzen effect@4.0.5. Sie sind noch nicht auf npm. Nutze sie aus dem Workspace mit "workspace:*".


Paket Wofür es da ist
openbot Der Server, seine Plugins und seine Model Providers. Siehe Aus Code starten.
api Der typisierte HTTP-Vertrag. Der Server, der Client und die API-Referenz nutzen ihn.
client Der TypeScript-Client. Siehe Client.
core Der Bot-Kern: Bots, Chats, Turns, Speicher, Plugins und Skills.
protocol Das OpenBot App Protocol: Apps veröffentlichen, finden, verbinden und indizieren.
protocol-test Ein öffentlicher Testdienst für das App Protocol. Siehe Protokolltest.
cli Der Befehl openbot. Für openbot <name> führt er openbot-<name> aus.
computer Der Befehl openbot computer. Er koppelt und verbindet deinen eigenen Computer.
docs Diese Doku-Seite.

Die Beispiele unten lassen das Model weg. Um sie auszuführen, gib einen LanguageModel-Layer an, zum Beispiel von einem Model Provider in openbot.


@openbot-build/core führt Bots aus. Es hat diese Hauptteile:

Modul Was es tut
Bot Eine Bot-Definition: Werkzeuge, Plugins, Provider, Compaction und Approvals. Eine Definition kann vielen Chats dienen.
ChatRuntime Führt Turns aus: deliver, execute, stream, interrupt, drain, wait und mehr.
ChatStore, ChatStoreRepository, Store Speichern Chats: einen kleinen Eintrag für jeden Chat und ein Log von Items, das nur wächst.
ChatStoreMemory Ein Speicher, der alles im Arbeitsspeicher hält, für Tests und Skripte.
Chat, Item, Turn, ChatState Die Datentypen. Chat hat auch den Dienst Approvals.
ContextCompaction, SummaryCompaction Machen einen langen Verlauf kürzer, damit er zum Model passt.
Plugin, PluginComposition, PluginState Plugins erstellen und kombinieren und ihnen gespeicherten Zustand geben.
Skill, SkillMap Skills beschreiben und eine Liste davon führen.

Ein Chat ist ein Gespräch ohne Ende. Jede Eingabe geht in seine Inbox (seinen Eingang). Sie startet einen Turn (Durchgang), oder sie lenkt den laufenden Turn. Ein Turn kann viele Model-Schritte und Werkzeug-Aufrufe haben. Ein Turn wartet nie: Er läuft, und dann endet er. Der Speicher hält die Items eines Chats in einem Log, das nur wächst. So bleibt ein langer Chat billig zu speichern.

import { ChatRuntime, ChatStoreMemory, Store } from "@openbot-build/core"
import { Effect } from "effect"
import { Toolkit } from "effect/ai"
const program = Effect.scoped(
Effect.gen(function* () {
const definition = yield* Store.define({
stores: {},
schemaVersion: "chat/1",
})
const store = yield* ChatStoreMemory.make(definition)
const runtime = yield* ChatRuntime.make({
store,
bot: { toolkit: Toolkit.empty },
})
const view = yield* store.create({})
return yield* runtime.execute({
chat: view.chat.id,
input: "Explain closures",
})
}),
)

Gib die Model- und Werkzeug-Dienste beim Bau an ChatRuntime.make. Seine Methoden behalten diese Dienste, auch für spätere Turns aus der Inbox. Gib optionale Funktionen als bot.approvals, bot.compaction und operations an. Ein Dienst im Kontext des Aufrufers schaltet keine Funktion ein. drain gibt Option<Delivery> zurück, mit None, wenn keine Eingabe starten kann. ConfigurationOperations.contextLimits gibt Option<Config.ContextLimits> zurück. Live.check gibt einen Effect zurück, der bei einer nicht unterstützten Fähigkeit mit LiveError fehlschlägt.

Ohne expectedRevision versucht store.modifyEffect einen Schreibkonflikt bis zu fünfmal erneut. Jeder Versuch liest den aktuellen Chat und führt die Änderung erneut aus. Sie darf deshalb keine Wirkung außerhalb des Stores haben. Mit einer festen expectedRevision schlägt ein Konflikt ohne neuen Versuch fehl.

Die Methoden von Operation.Store melden Speicherfehler als Operation.StoreError. get und finish geben Option<Record> zurück. None heißt, dass kein Eintrag passt oder kein neues Ende vorliegt. Jeder Aufruf kann mit RunInput.acquire Mittel in seinem Scope halten: während der Arbeit, beim Speichern des Endes und bei der Mitteilung. Schlägt der erste Schreibvorgang fehl, bleibt der Aufruf in seinem Turn. Schlägt das Speichern des Endes fehl, bekommt der Chat trotzdem seine Mitteilung. Der Fehler kommt ins Log.

Die unabhängige core-Runtime läuft in einem Prozess. Jeder Turn öffnet seine Plugin-Mittel in einem neuen Scope. Aufrufe im Hintergrund halten die nötigen Mittel, bis sie enden. core sendet keine Heartbeats oder Stopp-Anfragen an andere Prozesse. Nach einem Prozessende nimmt es die Arbeit nicht wieder auf. Der Server nutzt dafür sein System für dauerhafte Ausführungen.

execute startet einen Turn, wartet, bis er endet, und gibt den Chat zurück. stream liefert Ereignisse, während der Turn läuft. Beide schlagen fehl, wenn im Chat schon ein Turn läuft.

deliver gibt einem Chat eine Eingabe in einem Schritt, nach den Regeln der Inbox. Es startet einen Turn, wenn der Chat frei ist, lenkt den laufenden Turn, wenn die Eingabe denselben author hat, und wartet sonst in der Inbox. Es gibt started, steered oder queued zurück. Nach jedem Turn startet die nächste Eingabe in der Inbox den nächsten Turn.

Jeder Turn läuft in einer eigenen Fiber, und diese Fiber gehört der Runtime. Eine Fiber ist ein leichter Thread von Effect. Wenn der Scope der Runtime schließt, halten alle laufenden Turns an und enden als Interrupted. Das gilt auch für einen Turn, den ein Aufrufer mit stream oder execute liest. Eingaben, die in der Inbox warten, bleiben dort, und eine neue Runtime startet sie mit drain. Wenn ein Aufrufer von stream früh aufhört zu lesen, hält auch sein Turn an.

events liefert die Ereignisse eines Chats. Mit after liefert es zuerst die letzten Ereignisse nach dieser Revision, damit ein neuer Abonnent aufholen kann. Die Runtime behält dafür die letzten 64 Ereignisse. Ein langsamer Abonnent lässt den Speicher nicht wachsen: Wenn 1024 Ereignisse auf ihn warten, verdrängt jedes neue Ereignis das älteste. So kann ein solcher Abonnent Ereignisse verpassen, aber er bekommt immer die neuesten.

Ein Werkzeug mit needsApproval fragt vor jedem Aufruf. Die Runtime fragt dann den Dienst Approvals. Gib ihn als approvals in der Bot-Definition an. Er beantwortet jeden Aufruf mit einer von drei Entscheidungen:

  • Chat.Decision.Allow() führt den Aufruf jetzt aus.
  • Chat.Decision.Deny({ reason }) führt den Aufruf nicht aus. Das Model sieht den Grund.
  • Chat.Decision.Pending({ result }) führt den Aufruf jetzt nicht aus. Das Model bekommt sofort result, und der Turn geht weiter.

Ohne den Dienst wartet jeder solche Aufruf auf eine Entscheidung (pending). So wartet ein Turn nie.

Wenn ein Entscheider einen wartenden Aufruf später erlaubt, gib den Aufruf mit deliver und approved zurück. Die ID des Aufrufs muss neu sein. Der Turn, der die Eingabe liest, führt den Aufruf aus und fragt nicht noch einmal. Markiere Eingaben von deinem Programm mit notice, damit das Model weiß, dass sie nicht von einer Person kommen:

import { Chat } from "@openbot-build/core"
const runtime =
yield *
ChatRuntime.make({
store,
bot: {
toolkit,
approvals: {
decide: (request) =>
Effect.succeed(
Chat.Decision.Pending({ result: { pending: request.approvalId } }),
),
},
},
})
yield *
runtime.deliver({
chat: view.chat.id,
input: "[Host] A person allowed the email.",
notice: { kind: "request", ref: "req-1" },
approved: {
id: "approved-1",
name: "SendEmail",
params: { to: "team@example.com" },
},
})

Bot.Definition.registerTools öffnet Tools im Scope des Turns. Es gibt Tool-Definitionen, ihren Handler-Kontext, Anweisungen und Schritt-Hooks zurück. Es erhält weder eine Fortsetzung der Modellschleife noch einen Ereignisstrom. Alle Server-Funktionen nutzen diese Registrierung. Die Ressourcen bleiben offen, solange ein Hintergrundaufruf sie braucht. Der Host prüft die Regeln für Review und Plan erst nach der Registrierung aller Tools. Ein später ergänztes Tool kann diese Regeln nicht umgehen. Bot-Speicher wird erst gelesen, wenn das Modell seine Eingabe braucht, nicht beim Neubau der Tools für einen Hintergrundaufruf.

SummaryCompaction.prepare baut die Eingabe für eine Zusammenfassung ohne Modellaufruf. Es hält neue Nachrichten und verbundene Tool-Aufrufe zusammen. Ein TurnRunner-Host kann summarize(selection, input) geben, um das Modellergebnis vor der Änderung des Kontexts zu speichern. Der Standard für den eigenen Betrieb nutzt das Modell im aktuellen Scope. Der Server-Adapter nutzt dasselbe Aufrufprotokoll und dieselben Verbrauchsdaten wie andere Modellschritte. Nach einer Übernahme kann er die gespeicherte Zusammenfassung ohne neuen Modellaufruf lesen. Der Text der Zusammenfassung kommt nicht in die Textereignisse der Antwort.

Ein Plugin meldet Tools, Schritt-Hooks und Zustand an. Plugin.make erstellt es. Der Konstruktor läuft im Scope jeder Turn-Phase. Eine wartende Host-Aktion beginnt eine neue Phase. Am Ende, bei Fehler oder Abbruch schließen die Ressourcen. Ein Hintergrund-Tool hält sie bis zum Ende seines Aufrufs. Ein Baufehler lässt den Turn scheitern. Plugins können die Modellschleife nicht umgeben oder starten.

import { Plugin } from "@openbot-build/core"
import { Context, Effect } from "effect"
const logging = Plugin.make({ id: Plugin.Id.make("logging") })(() =>
Effect.succeed({
tools: {},
handlers: Context.empty(),
beforeStep: ({ kind }) =>
(kind === "model"
? Effect.log("A model step started")
: Effect.void
).pipe(Effect.as(true)),
}),
)

PluginComposition.merge(a, b) baut erst a, dann b. Hooks vor und nach dem Schritt laufen in dieser Reihenfolge. Gibt ein Hook vor dem Schritt false zurück, entfallen spätere Prüfungen und der Turn endet. Tool-Definitionen und Handler aus b haben Vorrang. ToolRegistration.merge nutzt dieselben Regeln für einfache Registrierungen. Die Laufzeit erhält Zeitgrenzen als Daten. Sie rechnet ab der gespeicherten Startzeit des Turns. Ein Neustart setzt die Grenze nicht zurück. Bei mehreren Grenzen gilt die kürzeste.

Ein Plugin kann Daten im Chat-Eintrag oder im Zustand speichern. Gib plugin.stores an Store.define und nutze dann PluginState.make.

Der Plugin-Speicher hat vier Operationen. get liest den Wert. snapshot gibt auch die Chat-Revision zurück. setAt schreibt nur, wenn diese noch stimmt. modify liest und ändert den Wert in einer Operation.

Die Funktion für modify gibt { result } zurück, um den Wert zu lassen, { result, value: Option.some(value) }, um ihn zu ersetzen, oder { result, value: Option.none() }, um ihn zu entfernen. Bleibt der Wert stehen, gibt es keinen Schreibvorgang und kein Ereignis. Die Funktion muss rein sein: Sie darf keine Uhr lesen, IDs erzeugen oder I/O ausführen. Bei einem Schreibkonflikt kann sie erneut laufen. Für I/O lies erst snapshot, führe die Arbeit außerhalb von modify aus und schreibe dann mit setAt. Eine alte Revision führt zu einem Fehler; setAt wiederholt die äußere Arbeit nicht.

Skill.Descriptor beschreibt einen Skill. SkillMap.Catalog ist eine Liste von Skills. Wenn zwei Listen dieselbe Skill-ID haben, gewinnt die spätere. Dein Programm findet, lädt und führt die Skills aus.


@openbot-build/openbot enthält alle Plugins von OpenBot. Der Server nutzt seine Plugins, zum Beispiel die Plugins für Bot, App und Arbeit, in jedem Turn. Das Arbeits-Plugin gibt dem Bot seine Todo-Liste, seine Erinnerungen und den Plan-Modus. Die Plugins für das Turn-Limit kannst du auch mit deiner eigenen ChatRuntime nutzen.

Auth.handle(request) gibt einen Effect mit der Antwort zurück. Chats.wait nimmt ein Duration.Input als Zeitgrenze, etwa "30 seconds". Die HTTP-Route nimmt weiter eine Zahl von Millisekunden als timeoutMs.

BotMemory enthält die Typen und Hilfsfunktionen für Bot Memory. Der Server nutzt sie für den Memory-Bot und die Memory-API. Die Schemas der Memory-API liegen in api, als BotMemory.

BotMemory.apply nimmt ein Array ids mit einer neuen ID je Operation in derselben Reihenfolge. BotMemory.edit nimmt eine neue ID als String id. Der Aufrufer erzeugt diese IDs vor dem Aufruf der reinen Hilfsfunktionen.

Drei Plugins setzen jedem Turn eine Grenze. Du kannst eines davon nutzen oder sie mit PluginComposition.merge verbinden. Jedes Plugin zählt nur seinen eigenen Turn.

TurnLimiter hält einen Turn nach einer festen Zahl von Model-Schritten an. Die erste Model-Anfrage zählt als ein Schritt.

import { TurnLimiter } from "@openbot-build/openbot"
const runtime =
yield *
ChatRuntime.make({
store,
bot: { plugin: TurnLimiter.make({ maxSteps: 4 }) },
})

Er schlägt mit einem von zwei Fehlern fehl:

Fehler Wann
InvalidMaxSteps maxSteps ist keine positive ganze Zahl.
ModelStepLimitExceeded Der Turn braucht nach der Grenze noch einen Model-Schritt. Der Fehler hat maxSteps und history.

TokenLimiter hält einen Turn an, wenn er eine feste Zahl von Tokens verbraucht hat. Ein Token ist ein kleines Stück Text, das das Model liest oder schreibt. Das Plugin zählt die Eingabe- und Ausgabe-Tokens jedes Model-Schritts zusammen. Ein Model-Schritt, der schon begonnen hat, darf fertig laufen. Der nächste Model-Schritt schlägt fehl, wenn die Summe maxTokens erreicht.

import { TokenLimiter } from "@openbot-build/openbot"
const plugin = TokenLimiter.make({ maxTokens: 100_000 })
Fehler Wann
InvalidMaxTokens maxTokens ist keine positive ganze Zahl.
TokenLimitExceeded Der Turn braucht nach der Grenze noch einen Model-Schritt. Der Fehler hat maxTokens, usedTokens und history.

TimeLimiter hält einen Turn nach einer festen Zeit an. maxDuration ist eine Zahl von Millisekunden oder ein Text wie "5 minutes". Wenn die Zeit um ist, hält das Plugin den Turn sofort an. Es hält auch einen Model-Schritt oder einen Tool-Aufruf mitten im Lauf an.

import { TimeLimiter } from "@openbot-build/openbot"
const plugin = TimeLimiter.make({ maxDuration: "5 minutes" })
Fehler Wann
InvalidMaxDuration maxDuration ist keine positive, endliche Zeit.
TimeLimitExceeded Der Turn läuft länger als die Grenze. Der Fehler hat maxDuration und history.

@openbot-build/openbot hat drei Model Providers. Jeder macht aus einer Model-Auswahl einen LanguageModel-Layer. Du gibst den API-Schlüssel an. Wie OpenBot sie nutzt, steht unter Models.

import { Config, Model, Provider } from "@openbot-build/core"
import { OpenAiCompatProvider } from "@openbot-build/openbot"
import { Effect, Layer, Redacted } from "effect"
import { LanguageModel } from "effect/ai"
import { FetchHttpClient } from "effect/http"
const selection = Config.Selection.make({
provider: Provider.Id.make("openai-compat"),
model: Model.Id.make("gpt-4.1"),
})
const model = Layer.unwrap(
OpenAiCompatProvider.make({ max_output_tokens: 1024 }).model(selection),
).pipe(
Layer.provide(
OpenAiCompatProvider.layerClient({
apiKey: Redacted.make(process.env.OPENAI_API_KEY ?? ""),
apiUrl: "https://api.openai.com/v1",
}),
),
Layer.provide(FetchHttpClient.layer),
)
const program = LanguageModel.generateText({ prompt: "Hello" }).pipe(
Effect.provide(model),
)

Lass apiUrl weg, um https://api.openai.com/v1 zu nutzen. reasoningEffort und serviceTier einer Auswahl ersetzen reasoning_effort und service_tier in den Standardwerten.

Dasselbe Muster, mit OpenAiProvider und der Provider-ID openai. Er nutzt die Responses API. Er hat auch ResponsesCompaction.layer(). Das macht lange Verläufe kürzer, mit der eingebauten Compaction der Responses API.

import { OpenAiProvider, ResponsesCompaction } from "@openbot-build/openbot"
const services = Layer.merge(
Layer.unwrap(OpenAiProvider.make().model(selection)),
ResponsesCompaction.layer(),
).pipe(
Layer.provide(OpenAiProvider.layerClient({ apiKey })),
Layer.provide(FetchHttpClient.layer),
)

Hole den Compaction-Dienst aus diesem Layer und gib ihn an den Bot:

import { ContextCompaction } from "@openbot-build/core"
const runtime = Effect.gen(function* () {
const compaction = yield* ContextCompaction.ContextCompaction
return yield* ChatRuntime.make({ store, bot: { compaction } })
}).pipe(Effect.provide(services))

Dasselbe Muster, mit AnthropicProvider und der Provider-ID anthropic. Er nutzt die Messages API.

import { AnthropicProvider } from "@openbot-build/openbot"
const provider = AnthropicProvider.make({ max_tokens: 1024 })

reasoningEffort kann low, medium oder high sein. serviceTier kann auto oder standard_only sein. Andere Werte schlagen fehl, bevor eine Anfrage gesendet wird.


@openbot-build/protocol setzt das OpenBot App Protocol um. Herausgeber nutzen es, um eine App ins Web zu bringen. Hosts nutzen es, um Apps zu finden, sich mit ihnen zu verbinden und sie aufzurufen.

import { Document, McpPublisher } from "@openbot-build/protocol"
import { Effect, Schema } from "effect"
import { McpServer, Tool, Toolkit } from "effect/ai"
const tools = Toolkit.make(
Tool.make("Hello", {
parameters: Schema.Struct({ name: Schema.String }),
success: Schema.Struct({ greeting: Schema.String }),
}),
)
const handlers = tools.toLayer({
Hello: ({ name }) => Effect.succeed({ greeting: `Hello, ${name}` }),
})
const origin = "https://apps.example.com"
const publisher = McpPublisher.layer({
publication: {
card: {
$schema: Document.cardSchemaUrl,
name: "com.example/hello",
version: "1.0.0",
description: "Say hello",
remotes: [
{
type: "streamable-http",
url: origin + "/mcp",
supportedProtocolVersions: [Document.protocolVersion],
},
],
},
cardUrl: origin + "/mcp/server-card",
catalogUrl: origin + "/.well-known/ai-catalog.json",
mcpUrl: origin + "/mcp",
allowedOrigins: ["https://host.example.com"],
},
register: McpServer.registerToolkit(tools).pipe(Effect.provide(handlers)),
})

Stelle publisher mit HttpRouter.serve und einem HTTP-Server-Layer bereit. Die vollständige Anleitung findest du unter Protokoll.