Skip to content

feat(api): let other apps chat with Sentient through an OpenAI-compatible API - #479

Draft
Tanmay-Ts wants to merge 2 commits into
existence-master:mainfrom
Tanmay-Ts:feat/openai-compatible-api
Draft

Tanmay-Ts wants to merge 2 commits into
existence-master:mainfrom
Tanmay-Ts:feat/openai-compatible-api

Conversation

@Tanmay-Ts

@Tanmay-Ts Tanmay-Ts commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

What and why

Closes #211 (draft: one question below is open)

Other apps on this computer (editors, scripts, shortcuts) can chat with Sentient, with its memory and tools, through any OpenAI client pointed at <engine>/v1, and they are never a way around approvals.

The API (sentient/gateway/routes/openai_api.py)

  • POST /v1/chat/completions, without streaming (a chat.completion with usage) and with stream: true (chat.completion.chunks, then data: [DONE]), and GET /v1/models (sentient).
  • Off until openai_api.enabled is on (OpenAIApiConfig, mirrored in desktop/src/lib/types.ts). /v1 takes only Sentient's own key, made with POST /api/openai-api/key (window token) into the keychain entry openai_api_key; never the window's token.
  • Each user gets its own named chat ("API" when absent) on channel: "api"; only the last user message is sent, because Sentient keeps the chat's history.
  • Errors use OpenAI's shape and carry x-should-retry: false: OpenAI's SDKs retry a 5xx twice by default, which would run the turn and its tools again (the real test caught that).

Approvals ask in the app (option A, as you chose in #211)

  • A call that needs the user's OK posts a kind: "approval" notification whose payload is the turn's approval_request plus session_id and source: "api". The window's existing Allow and Deny card for that kind answers it (POST /api/approvals), and the request waits for the answer.
  • Paired chats get it like a task's approval: held while the user is in the app, sent when they leave, through the channel's own approval message and ap: buttons (ChannelService.is_live_approval, _deliver_live_approval), which answer through app.approvals.
  • Its payload.status follows the answer, in the card's words: allow, deny, or expired (no answer within tools.approvals.timeout_s, or stopped). STATUS_LABELS gained allow and deny, so delivered messages settle.
  • A declined or unanswered call is never claimed: the reply (or a last delta.content) ends with Not done: "<tool>" needed your OK in Sentient and didn't get it. The model is already told NOT DONE...; this line makes it certain for a client that only sees text.

Stop everything and where it shows

  • Each API turn runs in its own task: Stop everything ends it (409 stopped, or a Stopped. error line when streaming), and an approval it waited on becomes expired. A client that hangs up no longer cuts the turn short; it runs to the end and is saved, like the window's.
  • The chat list marks these chats "From another app (API)" (desktop/src/features/channels/meta.tsx), and Activity says From another app (API), in the chat "<name>" (activity/timeline.py).

docs/API.md (section 2 "OpenAI-compatible API", section 14 delivery) and changes/211.added.md.

A note on "the existing path"

I couldn't find an engine path that already raises kind: "approval" notifications: task approvals pause the run and go out as kind: "task", payload.event = "question" with an approval block, answered through the task. What already existed for a live approval was the window's ApprovalActions card for kind: "approval" (unused by the engine until now) and each channel's send_approval() with ap: buttons. So this uses both, and makes ChannelService hold and send these the same way it does task approvals. If you meant a different path, tell me and I'll move it there.

Open question

Settings card. Settings has no place for this yet: a schema form only shows where a section's component places it, and making a key needs a button. Today it is turned on with PATCH /api/config {"openai_api": {"enabled": true}} and the key comes from POST /api/openai-api/key. Should I add a small card under Settings > Advanced (the switch, a "Make a key" button that shows the key once, and the base URL) in this PR, would you rather do the desktop part, or leave it for a follow-up?

How I tested it

  • tests/test_openai_api.py (14 tests): off by default; the key and the window's token; key rotation; /v1/models; replies with and without streaming; one named chat per user, last user message only; a request without a user message; a failed turn through the real OpenAI SDK saved once; and, for approvals, a call allowed in the app (and Activity's "From another app (API)"), denied (the Not done line, nothing written), not answered in time (expired), Stop everything while it waits (409, expired, nothing written), and a streamed turn that waits for the answer.
  • tests/channels/test_api_approvals.py (2 tests): an API turn's approval reaches a paired Telegram chat while nobody is in the app, and Allow there lets it carry on, Deny there means not done. Both fail with ChannelService unchanged.
  • Mutation checks: without the notification the approval tests fail; without the Not done line the deny and timeout tests fail; without x-should-retry the SDK test fails with ['hi', 'hi', 'hi'] == ['hi'].
  • pytest -q (Windows): 2735 passed, 2 skipped, with the two Windows fixes from fix(activity): find a chat tool call's details by its id, not its timestamp #486 applied on top (without them, those two tests fail on this machine as on main; see Activity loses a chat tool call's details on Windows when messages share a timestamp #485).
  • ruff check sentient tests packaging: clean. npm run typecheck not run locally (no node_modules here); CI runs it.

Real test

  • Setup: engine from this branch at 29995cb (python -m sentient serve --port 8792), new empty SENTIENT_HOME under .test-homes/, random SENTIENT_GATEWAY_TOKEN, holding .ollama.lock. Windows 11. Client: the official openai Python package (2.54.0), default settings.
  • Model: none answered (ollama_chat/qwen3:8b isn't installed here and Ollama wasn't running), so every turn ended in an error. That checks the wiring, auth, chats and error path, not a real reply.
1. API off, any key               -> HTTP 404 NotFoundError code='api_off'
2. PATCH /api/config {"openai_api": {"enabled": true}} -> 200 {'enabled': True}
3. POST /api/openai-api/key       -> key sk-sentient-..., base_url http://127.0.0.1:8792/v1
4. window token as the API key    -> HTTP 401 AuthenticationError code='invalid_api_key'
5. client.models.list()           -> ['sentient']
6. chat.completions.create(user="Notes app")
                                  -> HTTP 500 InternalServerError code='turn_failed'
                                     "Sentient couldn't reach Ollama. Make sure it's running, then try again."
7. ... stream=True                -> APIError with the same message
8. GET /api/sessions (channel api) -> [('Notes app', 'api')]

Before x-should-retry: false, step 6 saved the question three times for one request (the SDK's retries); after it, once.

  • Not tested for real: the approval flow (3dfb69d) on a real engine. It needs a model that makes a tool call, and there's none on this machine; the tests above script it with FakeProvider, including the Telegram delivery.

Checklist

  • Tests added or updated, and pytest / npm run typecheck pass locally for what I touched (typecheck not run locally, see above)
  • ruff check sentient tests is clean
  • docs/API.md updated if the desktop ↔ engine contract changed
  • A changelog entry in changes/ (changes/211.added.md)
  • No keys, tokens or personal data in the code, tests or screenshots

…ible API

POST /v1/chat/completions (with and without streaming) and GET /v1/models, off
until openai_api.enabled is on, behind their own key in the keychain
(POST /api/openai-api/key with the window's token). Each request's `user` gets
its own named chat on the "api" channel, and only the last user message is sent.

Errors use OpenAI's shape and carry x-should-retry: false, so OpenAI's SDKs don't
retry a failed turn and run its tools again.

Approvals for API turns and a Settings card are still open (questions in existence-master#211).

Refs existence-master#211
@github-actions github-actions Bot added documentation Improvements or additions to documentation area: engine Agent loop, models, approvals, storage, gateway area: desktop Electron app and UI labels Oct 19, 2026
@coderabbitai

coderabbitai Bot commented Oct 19, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

A call an API turn needs the user's OK for posts a kind "approval" notification
with the turn's approval_request: the window's existing Allow and Deny card
answers it, and paired chats get it like a task's approval (held while the user
is in the app), through the channel's own approval buttons. Its status follows
the answer (allow, deny, expired), so every place that shows it settles.

The request waits for the answer. A call that was declined or not answered in
time is never claimed: the reply ends with a line saying it was not done.

Each API turn runs in its own task: Stop everything ends it with "Stopped."
(and any approval it waited on), and a client that hangs up no longer cuts it
short. Chats and Activity say "From another app (API)".

Refs existence-master#211
@github-actions github-actions Bot added the area: channels Telegram, Discord label Oct 20, 2026
@Tanmay-Ts

Copy link
Copy Markdown
Contributor Author

@itsskofficial pushed 3dfb69d with option A, following your four points:

  1. Approvals ask in the app. A call an API turn needs the user's OK for posts a kind: "approval" notification (the window's Allow and Deny card), and paired chats get it like a task's approval: held while the user is in the app, sent when they leave, through the channel's own approval buttons.
  2. The client waits and is told the truth. The request waits for the answer; a declined or unanswered call ends the reply with Not done: "<tool>" needed your OK in Sentient and didn't get it., so it's never claimed.
  3. Where it shows. Chats and Activity say "From another app (API)".
  4. Stop everything ends a waiting API turn (409 stopped), and its approval becomes expired.

One thing to check with you: I couldn't find an engine path that already sends kind: "approval" notifications. Task approvals go out as kind: "task" questions answered through the task. So I reused the window's existing ApprovalActions card for that kind and each channel's send_approval() with ap: buttons, and taught ChannelService to hold and send these the same way as task approvals. If you had a different path in mind, I'll move it there.

The Settings card question is still open in the description, so I've kept this a draft for now.

@itsskofficial

Copy link
Copy Markdown
Contributor

@Tanmay-Ts, a heads-up from testing a similar PR (#487, sentient mcp). Please check this on yours before you mark it ready. When another app drives a chat turn, its text must not count as the user's own words. On #487, the client's text went into ctx.user_words (agent/loop.py ~1762), so an "Only if I asked" rule on code let the client run code on the host with no question in the app.

For /v1 turns, please:

  • mark the session as outside content from the start (like email tasks, ADR 0033);
  • keep the request text out of user_words;
  • check with a real run that an "Only if I asked" rule on execute_code still asks in the app when a /v1 client asks for code.

Your approvals-as-notifications approach is exactly right for the asking part.

This branch has not been deployed

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

Labels

area: channels Telegram, Discord area: desktop Electron app and UI area: engine Agent loop, models, approvals, storage, gateway documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An OpenAI-compatible API so other tools can use Sentient

2 participants