Ingest text manuals, FAQs, procedures, reference material

Upload a file .txt / .md

Ingest an external URL docs pages, wikis, public references

Import from Jira checking availability…

For up-to-date status the assistant's search_jira_tickets tool does a live lookup. Importing here builds lasting knowledge from closed or historical issues.

Register a page action navigate/click the agent can trigger by name

Ties an action to a scope, the same concept used above for knowledge base sources: any embed with a matching scope automatically picks this up, no per-page JavaScript to write. See Documentation → Agent page actions.

navigate, then click is for a button that only exists after navigating to a different page, e.g. an Angular/React admin app where "create X" means load a page then click "Add" once it's there. Navigating a page reloads the widget, this type survives that: it navigates on the first pass, then automatically continues and clicks once the new page has loaded.

scroll to brings an element into view on the current page without clicking or navigating, e.g. jumping straight to a specific section someone asks about.

Registered actions

    Register an internal API action named API call the agent can trigger

    Same scope concept as everything else here. The agent calls this by name with the listed parameters instead of guessing a URL itself, far more reliable than the free-form call_internal_api tool. Only URLs on the server's OAI_INTERNAL_API_ALLOWLIST can be registered.

    Registered internal API actions

      Schedule a task runs automatically, no one has to ask

      Same scope concept as everything else here. An internal API action or agent prompt runs headless on the server, no browser needed. A page action can only run while a browser tab with this widget open happens to poll for it — see Documentation.

      Scheduled tasks

        Discover API actions from ingested code scan-and-propose, nothing is registered until you approve it

        Pattern-matches ingested C# controller source for this repo/folder, not a real routing parser: an explicit [Route]/[RoutePrefix] is trusted as-is, everything else falls back to MVC's default {controller}/{action} convention and is flagged inferred. Verify inferred routes, and anything non-GET, against the source file before registering.

        Set scope instructions hard rules the agent follows whenever this scope is active

        Applied by the server in chat and in agent mode, whatever the embedding page sends and whatever is in the knowledge base. There is one set of instructions per project; saving again replaces it.

        A refusal phrase is applied by the server, so a decline always uses exactly these words instead of depending on how the model chooses to word it.

        Scope instructions

          Ingested sources

            Ingest a repo local path on this server

            Ingest a Bitbucket Cloud repo checking availability…

            Ingested repos

              Fine-tune a model checking availability…

              Runs on CPU on this server, expect minutes, not seconds, even for tiny datasets. Once finished, the trained model is registered in Ollama automatically and can be set as the chat model.

              Fine-tuning jobs

                Create an API key requires an admin key

                Anything embedded client-side (a widget's window.OAI_EMBED_CONFIG.key, a ?key= URL, or a data-key attribute) is visible to anyone who views the page's source, there is no way to keep a value in the browser secret. Always set scopes for a key used that way, and use the act role, not write: a scoped key can only ever reach the scope(s) listed here even if it leaks, and act can run the agent and trigger already-registered actions without also being able to ingest content or register new ones. Leave scopes blank, and use write/admin, only for a real server-to-server integration that's never sent to a browser.

                API keys

                  Internal API allowlist base URLs the agent is ever allowed to reach

                  Controls both call_internal_api (free-form, write-role only) and registering/calling a named internal API action. Entries added here take effect immediately, no server restart, unlike the OAI_INTERNAL_API_ALLOWLIST environment variable, which is the deploy-time baseline: set once by whoever runs the server, shown below but not removable from this panel (edit the server's environment and restart to change it).

                  Allowed base URLs

                    Audit log every authenticated call made against this server

                    Who called what, when, and whether it worked. Requests are listed without their content. A TOOL CALL row shows which tool the assistant used in a turn and, when it failed, why. Entries are kept for 30 days.

                      All projects

                      Pick a project to manage it. A project holds one assistant's knowledge, actions and instructions, plus the apps that connect to it.

                      Get this project working

                      The usual order. Each step has its own section in the menu on the left.

                      1. Add knowledgeDocuments, web pages and connected systems the assistant answers from.
                      2. Say what it may doRegister actions it can run, and the addresses it is allowed to call.
                      3. Set its instructionsTone, rules and what to say when it does not know.
                      4. Connect an appEach website, kiosk or service that uses this assistant gets its own key.

                      Users

                      People who can sign in. Give each person access to the projects they work on under that project's Members.

                        Members

                        People who can open and manage this project. Administrators can always see every project.

                          Connected apps

                          A connected app is any website, kiosk or service that uses this project's assistant. Each gets its own key, so you can switch one off without touching the others. An app can only ever reach this project.

                            Keys that can change content (write or admin) are server-wide and are issued by the person who runs the server, not from here.

                            Getting started

                            OAI is a self-hosted AI assistant platform. It runs entirely on your own server using open-source models, so there is no per-use cost and your data never leaves your infrastructure. You can use it three ways: as a chat assistant in this portal, as an agent that takes real actions through your systems, or embedded inside your own apps and websites.

                            The building blocks:

                            • Knowledge — documents, web pages and code the assistant can search and cite.
                            • Actions — things it can do: call your APIs, read live data, click or navigate inside a page.
                            • Instructions — rules and tone for a project or app, enforced on the server.
                            • Projects & keys — each app or team gets its own scope and API key, so nothing leaks between them.

                            Finding your way around. Projects is the starting point: a project is one assistant, with its own knowledge, actions, instructions and allowlist. Open a project and its sections appear in the menu (Knowledge, Code, Actions, Instructions, Allowlist, Connected apps, Members, Activity log). A connected app is any website, kiosk or service that uses the project's assistant, and each has its own key. Administration (administrators only) holds the people who can sign in and the server-wide keys.

                            Required models and voice files download automatically the first time the server starts, so there is nothing to install by hand. Open Administration → API keys to create a key for an application; every request to the server needs one. Click the key pill in the top bar to sign in with yours.

                            Attaching images, audio, and files to a chat message

                            The paperclip button next to the message box accepts images, audio recordings, and small text-ish files (.txt, .md, .csv, .json, .log, and a few source-code extensions), and each is handled differently:

                            • Images ride along in the chat request itself (the same images field /v1/chat/completions and /v1/agent/run already accept) and route to the vision model, same as calling /v1/vision/describe directly.
                            • Audio is transcribed the moment you attach it (via /v1/audio/transcriptions) -- you'll see "Transcribing..." then a preview of the result on the chip before you send, so you can remove it if the transcription looks wrong.
                            • Text files are read in your browser and inlined into your message as context, labeled with the filename.

                            This is deliberately not the same thing as Knowledge Base ingestion (the Documents tab): an attachment here is context for this one message only and is never stored for future sessions. To make a document permanently searchable across every future chat, ingest it in the Knowledge Base tab instead.

                            Chat vs. Agent mode

                            The Chat tab has a mode switch above the message box:

                            • Chat: plain question-answering. It automatically pulls in relevant snippets from your ingested documents (not code) and cites them as source pills under its reply.
                            • Agent (tools): the model can call tools to look things up or take action before answering: searching the knowledge base, searching ingested code, checking the time, or calling an internal API. When it uses a tool, you'll see "tools used: ..." under the reply.

                            Both modes remember the conversation via the Session ID field: it fills in automatically after your first message. Reuse the same session ID to continue a conversation with memory intact; clear the field to start fresh.

                            By default, both modes only answer from what's actually been ingested. If nothing relevant is found, they say so instead of guessing from the model's own general knowledge. API callers can opt into the old fall-back behavior with "allow_general_knowledge": true on the request; the portal itself always uses the strict default.

                            Training the knowledge base

                            "Training" here means retrieval, not fine-tuning the model itself: text you ingest is chunked, embedded, and stored so relevant pieces can be pulled into context automatically at chat time. This is how you teach OAI about your products, procedures and reference material.

                            In the Knowledge Base tab you can:

                            • Paste text directly with a source label (e.g. product-manuals)
                            • Upload a .txt or .md file

                            Re-ingesting the same source label replaces its old content, so it's safe to update a document by re-pasting it. Keep source labels short and descriptive. They're shown as citations in chat answers.

                            Codebase ingestion

                            The Codebase tab lets OAI learn a production repo so it can answer questions about your own code and help apps understand each other. Give it a repo name and a local filesystem path on the server (a path the server process can read, e.g. a cloned repo).

                            It walks the directory, skips build/vendor noise (.git, node_modules, venv, dist, etc.), and ingests each source file, tagged as repo-name:path/to/file. Re-ingesting a repo cleanly replaces its old contents, so it's safe to re-run after a pull.

                            Code and business documents are kept separate: plain Chat and Knowledge Base → Sources never show code files, and code is only searched through the search_codebase tool in Agent mode.

                            External sources: URLs, Bitbucket, Jira

                            External URLs: Knowledge Base → Ingest can fetch and ingest any public web page (docs sites, wikis, reference pages). No configuration needed; it's treated the same as pasted text.

                            Bitbucket Cloud: Codebase → Ingest can clone and ingest a Bitbucket Cloud repo directly, the same way local repos work. Requires OAI_BITBUCKET_TOKEN (a Bitbucket Cloud repository or workspace access token) set on the server; the token is never exposed to the portal or stored anywhere but the server's environment.

                            Jira Cloud: two different ways to use Jira, for two different needs:

                            • Knowledge Base → Ingest can ingest tickets matching a JQL query, good for building lasting knowledge from closed or historical issues ("how did we resolve this before?").
                            • The agent's search_jira_tickets tool does a live lookup instead, better for current status, since ingested tickets go stale the moment they're updated.

                            Requires OAI_JIRA_SITE, OAI_JIRA_EMAIL, and OAI_JIRA_API_TOKEN set on the server (Atlassian Cloud only).

                            Fine-tuning

                            Knowledge Base and Codebase teach OAI facts via retrieval: the model looks things up at answer time. Fine-tuning is different: it actually adjusts the model's weights so it internalizes a style, tone, or behavior pattern without needing to retrieve anything. Use it for things like "always answer in this format" or "adopt this persona," not for teaching it new facts (RAG is the right tool for that).

                            The Fine-tune tab runs a real LoRA fine-tuning job on this server:

                            1. Give the job a name and provide training examples as {"instruction": "...", "response": "..."}, one per line.
                            2. It trains a small open base model (Qwen/Qwen2.5-0.5B-Instruct by default, swappable) with a LoRA adapter, merges the adapter into the base weights, converts the result to GGUF, and registers it as a new Ollama model automatically.
                            3. Once the job's status is done: set that job's model name as OAI_CHAT_MODEL (see the job list for the exact name) to chat with the fine-tuned model.

                            Every job in the list shows how many training examples it used, and View training data shows the exact examples, so what a given model was actually trained on is never a mystery weeks later. Admins can Delete a job: this removes its dataset and log files and its registered Ollama model (best-effort, in case it was never successfully registered), and refuses while the job is still running rather than killing it silently.

                            Set expectations honestly: this runs on CPU (no GPU on this server), so even a handful of examples takes minutes, not seconds. It's meant for small demonstration datasets: a handful to a few dozen examples. A tiny dataset (a handful of examples, 1 to 3 epochs) is enough to prove the pipeline works, but usually isn't enough training signal to reliably override what the base model already "knows." Expect to need more examples and/or more epochs, and to check the result actually changed the behavior you wanted before relying on it.

                            The fine-tuning environment is a separate Python installation from the main API (set up once via py -3.12 -m venv finetune/.venv312 then installing finetune/requirements.txt into it). This keeps heavy ML dependencies isolated from the API server.

                            Embedding OAI in other apps

                            Drop a floating chat widget into any internal app. Two lines in the host app's HTML:

                            <script>
                              window.OAI_EMBED_CONFIG = {
                                base: "http://<your-oai-host>:8787",
                                key: "an-api-key",
                                mode: "agent",           // "chat" or "agent"
                                title: "Orders Assistant",
                                scope: ["orders-service", "billing-service"], // optional: restrict to one or more repos/sources
                                context: [                                     // optional: ground it in what the user's looking at
                                  "Viewing Order #4521",
                                  "Customer is on the Enterprise plan"
                                ]
                              };
                            </script>
                            <script src="http://<your-oai-host>:8787/portal/embed-loader.js"></script>

                            That's it: a floating launcher button appears, opening a chat panel backed by your OAI server. The panel runs in an iframe served from your OAI server itself, so the host app never talks to the API directly and needs no CORS setup.

                            scope restricts retrieval/tools to sources starting with that prefix, e.g. a repo name, so the embedded assistant only searches those sources. context is freeform text describing what the user is currently doing in the host app; it's passed to the model as grounding for its first reply. Both accept either a single string (for one scope/one context item) or an array (to combine several), e.g. an assistant that should search both a codebase and a set of manuals, or that needs several separate facts about the current screen.

                            To update context after the user navigates within a single-page host app, without reloading the whole widget: window.OAIEmbed.setContext("Viewing item #9981") or window.OAIEmbed.setContext(["Viewing item #9981", "Status: shipped"]). Other available calls: OAIEmbed.open(), .close(), .toggle().

                            Security note: the API key is visible in the host page's source and network requests. Use a key scoped to only what the embed needs (usually write, for chat + agent; never an admin key) rather than reusing a broad admin key.

                            Attaching help to a specific input field

                            The floating widget is for general questions. For helping a user who's stuck on one specific field, for example a customer who doesn't know what to type, or wants their draft text improved, attach a small helper button directly to that field instead:

                            <textarea
                              data-oai-field
                              data-oai-label="Reply to message"
                              data-oai-context="The user is writing a reply about..."
                              data-oai-scope="support-kb,billing-kb"
                            ></textarea>
                            
                            <script>
                              window.OAI_FIELD_CONFIG = { base: "http://<your-oai-host>:8787", key: "an-api-key" };
                            </script>
                            <script src="http://<your-oai-host>:8787/portal/field-loader.js"></script>

                            This adds a small button anchored to the field. Clicking it opens a compact popover with two tabs: Help (ask a question about that field, grounded in data-oai-context) and Enhance my text (sends the field's current contents to be improved, then applies the result back into the field with one click, the way a real form would fire its own change handlers, not just a raw value swap).

                            Fields can also be attached from JavaScript instead of data attributes: window.OAIField.attach(selector, { label, context, scope, mode }), useful when the label or context needs to be computed dynamically. From JavaScript, scope and context both accept an array to combine multiple values (e.g. scope: ["support-kb", "billing-kb"]); from a data attribute, scope accepts a comma-separated list (data-oai-scope="support-kb,billing-kb") since context is free text and stays a single string there to avoid ambiguity with commas inside it.

                            Being honest about reliability: "Enhance" occasionally gets an odd or overly cautious response from the small model backing this (verified live while building this feature), the same Try again button that's always visible handles that; nothing gets applied to the field until you click Use this.

                            Letting the agent act in the host page

                            Beyond calling internal APIs, an embedded agent can act directly in the page the user is looking at: navigate it, click something, fill in a form field, "create a new article, then help fill it in." This can't run on this server, only the browser the user is looking at can click a real button, so a turn requesting this pauses (action_required), the embed widget relays the request to the host page over postMessage, and resumes the same turn once the host page reports back what happened.

                            Three ways the agent picks a target, in order of preference, none invented by the model:

                            • Registered actions, tied to a scope (recommended): the same scope concept used for knowledge base sources above. Register one via Agent Setup → Page Actions, or POST /actions: { scope: "my-app", name: "create_article", description: "...", type: "navigate", url: "/admin/Article/Edit/0" }. Any embed with a matching scope automatically gets it, no per-page JavaScript to write or keep in sync.
                            • window.OAI_ACTIONS (page-local, still supported): the same shape, declared directly in a page's script instead, for an action you deliberately want to keep off the server. Consulted as the fallback when no registered match is found for the name the model called.
                            • Fallback (best-effort, nothing to register either way): click_page_element / fill_page_field search the live page for a link/button matching given text, or a field matching a given label. Looser than a registered action, only rely on it where a wrong guess is low-stakes.

                            Either of the first two, the model can only trigger an action by its exact registered name, never anything else. Supported types: navigate (url), click (selector), navigate_then_click (url + selector), scroll_to (selector, brings an element into view without clicking or navigating). Include portal/actions-loader.js in the host page alongside embed-loader.js, it's what actually performs the action once asked, whether the definition came from the server or its own local window.OAI_ACTIONS.

                            Being honest about reliability: verified live end-to-end both ways, a registered (scope-tied) and a declared (window.OAI_ACTIONS) navigate action both correctly navigated the real browser to a real different page, the registered version had the model call it correctly on the first real tool call, no rescue needed. Less reliable: getting the small default model to pick fill_page_field/click_page_element over hallucinating a made-up action name, even a form-filling request phrased explicitly sometimes gets routed to perform_action with an invented name instead. That fails safely (an unregistered name just gets "not found" back, nothing wrong is performed), but it doesn't always self-correct to the right tool. Actions in on-the-nose language work well; open-ended fallback requests are more of a coin flip with this default model. Full writeup, including the exact protocol, in the README.

                            Speed: every page-interaction tool's schema (9 of them) gets sent to the model on every single agent turn, real prompt tokens, real CPU prefill time, whether or not the conversation ever uses one. POST /v1/agent/run takes an optional enable_page_tools (default true), set it to false for any agent conversation with no host page to act on, a plain server-to-server integration, a test/admin chat like this portal's own Agent (tools) tab (which already does this). Measured live: a single-tool-call turn went from ~75s to ~22s with page tools left out, on identical CPU-only hardware. Leave it at the default for anything running inside an actual embed, it still needs those tools.

                            Tools the agent can use

                            • search_knowledge_base: searches ingested business documents (same retrieval as plain Chat)
                            • search_codebase: searches ingested source code, optionally scoped to one repo
                            • get_current_time: current UTC date and time
                            • call_internal_api: calls an internal HTTP API to fetch data or trigger an action. Refuses every URL by default. An admin has to allow its base URL first, either under Admin → Allowlist (effective immediately, no restart) or via OAI_INTERNAL_API_ALLOWLIST (comma-separated, server env var, the deploy-time baseline, not removable from the portal). If the real service needs an Authorization header or an API key header on every call, set it once per base URL from the same Allowlist panel ("Add headers"/"Edit headers"), it's attached automatically to every request OAI makes to that base, both call_internal_api and registered API actions. Header values are write-only from the portal, never shown back once saved, only the header name is visible afterward. This is what lets your own software interoperate through OAI safely.
                            • Login-based auth (Admin → Allowlist, "Set up login flow"): for a real app whose auth is a session cookie or a token issued on login, not a static key you can just paste. Describe the login call once (login URL, method, JSON or form-encoded body with {username}/{password} placeholders, and where the credential shows up in the response, a cookie name or a JSON field), then click Login now whenever you need a fresh session, it actually logs in and writes the extracted cookie/token into this same URL's headers, exactly as if you'd pasted it in by hand. Nothing downstream needs to know or care which way a base's auth was set up, call_internal_api and every registered API action read it through the same lookup either way. Credentials are stored server-side, write-only like everything else here, never returned by any GET. Sessions eventually expire (check your app's own session timeout), there's no automatic background refresh, "Login now" is a manual, on-demand action.
                            • perform_internal_api_action: calls a named internal API action registered for this conversation's scope (see Agent Setup → API Actions). The model picks it by name instead of constructing a URL itself, more reliable than call_internal_api for anything you can pre-register. Also gated by the same allowlist, both at registration and at call time.
                            • search_jira_tickets: live search of Jira tickets by free text. Requires Jira to be configured on the server.
                            • perform_action / click_page_element / fill_page_field / scroll_page / scroll_to_element / highlight_page_element / read_page_content / select_page_option / toggle_page_checkbox: act on, or read, the host page the user is looking at. Client-executed, see "Letting the agent act in the host page" above.

                            New tools are added in api/tools.py by the person running this server.

                            Discovering API actions from ingested code

                            Registering an internal API action by hand means reading a controller file to find its real route. Agent Setup → Discover (or POST /codebase/scan-endpoints: { repo, path_prefix, base_url, url_path_prefix }) automates the reading, not the registering: it pattern-matches ingested C# controller source under a repo/folder and returns a list of candidate endpoints, nothing is saved until you explicitly approve some of them.

                            App path (url_path_prefix, optional): a controller's route is relative to wherever IIS mounted the app, which the source code itself never states. If the base URL's host serves more than one app (e.g. base URL http://api.my-app.example.com but this particular app actually answers at http://api.my-app.example.com/MyApp/...), set the app's virtual path here, it's inserted between the base URL and every discovered route. Left blank, routes are joined straight onto the base URL as before.

                            Each candidate is honest about how sure it is. An explicit [Route(...)]/[RoutePrefix(...)] is trusted as written; without one, the route falls back to MVC's default {controller}/{action} convention and is labeled inferred, meaning not checked against the app's actual routing config, verify it against the source file (linked per candidate) before trusting it. Same idea for the HTTP method: an explicit [HttpPost]/[AcceptVerbs(...)] is trusted, otherwise it defaults to GET and says so. Detected parameters that aren't already reflected as a {placeholder} in the URL are flagged too, they're registered but inert until you edit that action's URL/body template to actually use them.

                            Request body auto-fill: a POST/PUT action taking a single complex parameter (e.g. CreateOrUpdateSuperadmin(SuperAdminModel superadmin)) isn't left unwired if that model class's own definition is also ingested somewhere in the same repo, even outside the scanned folder: its public auto-properties (public string Name { get; set; }) are resolved and turned into a flat body template ({"Name": {Name}, ...}, matching how ASP.NET's default model binder actually treats a single [FromBody] parameter, the request body is that object's JSON, not wrapped under the original parameter's name), with one param per field instead of one opaque, useless object param. Best-effort, same as everything else here: a class that isn't ingested at all, uses plain fields instead of { get; set; } auto-properties, or is a modern C# record, won't resolve, and the param is left exactly as unwired as before this existed, never a wrong guess. If it doesn't resolve for a class you know exists, make sure that class's own file is ingested under the same repo name, not just the controller.

                            The review list pre-checks GET candidates and leaves everything else (POST/PUT/DELETE, anything state-changing) unchecked on purpose, matching how every other action registration here works: the model can only ever trigger a name it was explicitly given, and now the admin can only approve what they explicitly checked. "Register selected" registers each checked candidate as a normal api-action via the same endpoint the manual form uses, editable and removable the same way afterward.

                            Scope instructions

                            Knowledge sources and registered actions control what a scope can reach. Scope instructions control what it shouldn't do, regardless of that. Register free text via Agent Setup → Scope Instructions, or POST /scope-instructions: { scope: "my-app", instructions: "Keep answers short and friendly. If you are not sure, say so and suggest who to contact." }. One set of instructions per scope, re-registering replaces it, same convention as everything else here.

                            Injected into the system prompt server-side, framed as a hard constraint that overrides anything else in the prompt, for both /v1/chat/completions and /v1/agent/run whenever a request's source_scope matches. This is deliberately separate from a widget's context field: context is supplied by the embedding page's own JavaScript, plain text anyone who can edit that page can change; scope instructions live on the server, apply no matter what the embed sends, and can't be overridden by editing the host page. A scope can have both: context for per-conversation grounding info ("the user is viewing item #1042"), scope instructions for the standing rule that should hold in every conversation touching that scope.

                            The free-text instructions guide the assistant rather than filter it: specific, direct rules (“never state a price”) are followed reliably, while open-ended or subtle ones are followed less consistently, so prefer concrete wording. Enhance, next to Save, suggests a clearer, more complete version of what you have written (or a first draft if the box is empty). It only suggests: nothing changes until you press Use this, read it, and Save.

                            Refusal phrase (optional, set alongside instructions): the exact wording used whenever this scope must refuse, e.g. { scope: "orders-app", instructions: "Only answer questions about the customer's order.", refusal_phrase: "I can only help with questions about your order." }. This one is mechanically enforced, not just requested: it replaces the base prompt's default refusal instruction entirely, and every server-side backstop that force-corrects an ungrounded or hallucinated answer (an agent turn that consulted nothing, a fake tool call trying to dodge refusing) substitutes this exact text, not the generic system default. Without it, "say X" in your instructions only shows up when the model happens to type X correctly, which is precisely where it tends to break under repeated pressure, instead it may invent something else (seen live: it fabricated a nonexistent page action named after the refusal sentence rather than just writing it as text). Leave blank to use the system default, "I don't have that information in the knowledge base."

                            Keeping knowledge up to date

                            Knowledge that comes from something that changes (a page, a list of events or products, a set of Jira issues, a repository) can be kept in step automatically. Under Knowledge → Add knowledge, Keep knowledge up to date takes the source, how often to read it, and a name. Look at it first reads the source once and shows what would be kept; for a list of records it also shows the fields, and you choose which ones the assistant should know.

                            • What a read does: adds new things, rewrites things that changed, and removes things that have gone from the source. Nothing is processed again that has not changed, so frequent reads are cheap.
                            • Which addresses: web pages and data feeds can only be read if the address is approved in the project's Allowlist; the form offers to approve it.
                            • If something goes wrong: a read that fails, or finds nothing, changes nothing. If most of a feed's entries would disappear at once, they are kept and the feed says Needs a look; Apply removals confirms it. A feed that fails five times in a row is paused.
                            • Managing: the list under Knowledge → Sources shows each feed with its last result, with Read now, a schedule, Pause and Remove. Removing a feed removes the entries it made. Things you added by hand are never touched.

                            Assistant design, diagram and documentation

                            Each project has three pages for the team, in the project menu. Everything on them is saved on the server and shared by everyone who can open the project.

                            • Assistant design: choose how the assistant looks on a website (colours, fonts, corners, header, logo, launcher button, window). The preview is the real assistant, so what you see is what visitors get. Save the design, then copy the snippet or just its style into your site. Replace YOUR_API_KEY with the key of a connected app.
                            • Diagram: a picture of how the project is put together, drawn from what is really in it (connected apps, scheduled tasks, knowledge, code, page and API actions, the systems it reaches, instructions and team), so it is never out of date. Drag boxes to arrange them, click one to see what is inside it, and add notes, external systems and lines for anything the diagram cannot know about. Arrangements save automatically. Download it as SVG or PNG for a document or a presentation.
                            • Docs: written pages for the project, in Markdown with a live preview. A page can be about the whole project or about one connected app, and then it also appears under that app (Connected apps, Details). Pages can be downloaded as Markdown.

                            API keys, roles & integrating other apps

                            Every endpoint except /health and this portal page requires an X-API-Key header. Each key has a role that limits what it can do, each tier includes everything below it:

                            • read: chat, list documents/repos, view memory
                            • act: read + run the agent, trigger already-registered page/API actions
                            • write: act + ingest documents & repos, register new actions, clear memory
                            • admin: write + delete documents/repos, create/list/delete API keys, fine-tuning

                            Use act for anything embedded client-side, not write. A widget only ever needs to run the agent and trigger actions someone already registered, it never needs to ingest new content or register new actions itself. Keeping those behind write means a leaked embed key, or a page trying to prompt-inject the agent into doing something it wasn't asked for, can still only reach what that specific widget is meant to do, not add documents, register new internal API actions, or touch fine-tuning. This is enforced twice: once on which REST endpoints the key's role can call directly, and again inside the agent's own tool loop (call_internal_api, a free-form call to any method/body on an allowlisted base URL, requires write even for a key that's otherwise allowed to run the agent; perform_internal_api_action, restricted to a specific pre-registered URL template, doesn't).

                            Deletion and registration are deliberately kept above act, so an embed key can add nothing and remove nothing, it can only use what an admin/write key already set up. If a key tries something above its role, the request fails with a clear 403 rather than silently succeeding or failing oddly, and this portal only shows you the sidebar sections and controls your key can actually use. Manage keys either from Admin → API Keys (needs an admin key) or from a terminal on the server:

                            python scripts/manage_keys.py create my-app-name act my-app
                            python scripts/manage_keys.py list
                            python scripts/manage_keys.py delete my-app-name

                            The key is only shown once, at creation. Deleting a key removes it from the database entirely and invalidates it immediately. Every request is logged (app name, path, status, timestamp) for basic auditability. Any internal app can call OAI the same way you'd call any other JSON API: point it at this server's address plus the endpoint path, and send the key on every request.

                            Scopes, and why they matter for embedded widgets specifically: a key can optionally be restricted to a list of scopes at creation (or edited in place afterward, under Admin → API Keys or via PATCH /admin/keys/{app_name}). Enforced server-side on every request that takes a source_scope/scope parameter (chat, agent, registering page/API actions): a scoped key can only ever touch the scope(s) it was actually given, no matter what scope the caller claims in the request. Leaving scopes blank makes a key unrestricted, and that's no longer the default from just omitting the field, an unrestricted key now requires explicit confirmation (a checkbox here, or confirm_unrestricted: true / the literal word unrestricted via the CLI), so it can't happen by accident.

                            This isn't optional for anything embedded client-side. window.OAI_EMBED_CONFIG.key, a data-key attribute, a ?key= URL param, all of it is plain text visible to anyone who opens DevTools or views page source on the host page, there's no way to keep a browser-side value secret from the person running that browser. An unrestricted key used this way lets anyone who lifts it act as that key everywhere on the server, not just in the widget they found it in. Always create a scoped, act-role key for a widget/embed; reserve unrestricted write/admin keys for real server-to-server integrations that never reach a browser.

                            API quick reference

                            All routes below require X-API-Key: <key>.

                            • POST /v1/chat/completions: plain RAG-augmented chat. Optional source_scope (restrict retrieval to a source prefix, string or array of strings) and context (host-app grounding text, string or array of strings) fields. Optional allow_general_knowledge (default false). Any message may include images: [base64, ...] (no data-URL prefix) — a request with an image anywhere in it routes to the vision model instead, see /v1/vision/describe below.
                            • POST /v1/vision/describe ({ "imageBase64": "...", "prompt": "optional" } → { "description": "..." }): one-shot image-to-text, no RAG, no session/history. Requires the vision model pulled (see Getting started above).
                            • POST /v1/agent/run: chat with tool-calling; response includes a tool_calls trace. Same optional source_scope / context / allow_general_knowledge fields. Optional available_actions ([{name, description}]) describes host-page actions the agent can trigger. A turn can pause instead of finishing: response includes action_required: {tool, arguments} and no final answer; resume it with another call carrying the same session_id, empty messages, and pending_action_result: {tool, arguments, result}. See "Letting the agent act in the host page."
                            • POST /documents/text / POST /documents/upload: ingest business docs
                            • GET /documents / GET /documents/{source} / DELETE /documents/{source}: list docs (read) / fetch a doc's stored text (read) / remove a doc (admin)
                            • POST /codebase/ingest: ingest a local repo
                            • GET /codebase/repos / DELETE /codebase/{repo_name}: list repos (read) / remove a repo (admin)
                            • POST /external/url: ingest a public web page. Optional deep: true to also follow same-site links found on the page (BFS, up to max_pages and max_depth), ingesting each discovered page as its own source.
                            • POST /external/bitbucket ({ "workspace": "...", "repo_slug": "...", "branch": "optional" }): clone & ingest a Bitbucket Cloud repo
                            • POST /external/jira/ingest ({ "jql": "...", "max_results": 50 }): ingest matching Jira tickets
                            • GET /external/status: whether Bitbucket/Jira are configured
                            • GET /memory/{session_id} / DELETE /memory/{session_id}: inspect / clear a conversation
                            • GET /whoami: the current key's app name and role
                            • GET /admin/keys / POST /admin/keys (optional scopes: string[]; omitting/emptying it requires confirm_unrestricted: true, otherwise the request is rejected rather than silently creating an unrestricted key) / PATCH /admin/keys/{app_name} ({role} and/or {scopes}, only the fields present are changed; clearing scopes needs the same confirm_unrestricted: true) / DELETE /admin/keys/{app_name} (hard delete): manage API keys (admin role only)
                            • POST /actions (write) / GET /actions?scope=optional (read) / DELETE /actions/{id} (admin): register/list/remove agent page actions. Re-registering the same (scope, name) replaces it.
                            • POST /api-actions (write) / GET /api-actions?scope=optional (read) / DELETE /api-actions/{id} (admin): register/list/remove named internal API actions the agent can call by name. url_template must be on OAI_INTERNAL_API_ALLOWLIST, checked at registration time and again at call time. Optional body_template for POST/PUT/PATCH: JSON where a {param} placeholder sits where a value goes (e.g. {"status": {status}}, not in quotes), each one substituted with the parameter's actual value, correctly quoted/escaped for its type. Re-registering the same (scope, name) replaces it.
                            • GET /finetune/status: whether the fine-tuning environment is set up
                            • POST /finetune/jobs ({ "name": "...", "dataset": [{"instruction":"...","response":"..."}], "epochs": 3 }) / GET /finetune/jobs (includes each job's dataset_count) / GET /finetune/jobs/{job_id}/log: run and monitor fine-tuning jobs
                            • GET /finetune/jobs/{job_id}/dataset: the exact training examples used for that job, so what a model was actually trained on is never a mystery after the fact
                            • DELETE /finetune/jobs/{job_id}: remove a job, its dataset/log files, and its registered Ollama model (best-effort). Refuses while the job is still running. Admin only, same as other deletions.

                            Example integration call from another app:

                            curl -X POST http://<host>:8787/v1/agent/run \
                              -H "X-API-Key: your-key" \
                              -H "Content-Type: application/json" \
                              -d '{"messages":[{"role":"user","content":"..."}]}'