Repository navigation
feat(sdk): add the Platform API and managed-user mode - #6
Merged
Merged
Conversation
A tenant client built with api_key can create and get managed users through client.platform.managed_users, with typed ManagedUser values and PlatformError failures that never carry upstream bodies. Adding managed_user acts as that user: REST requests send the key and a Pine-Managed-User header, the Socket.IO handshake sends token and managed_user, and connect() resolves the Pine user ID through auth.me(). client_name adds a Pine-Client header to every REST request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
client_name now also goes to the Socket.IO handshake as a Pine-Client header, where the backend reads it for attribution. The identity headers become a method of a base shared by both HTTP transports instead of a module function reading their private fields. The README states that the backend records Pine-Client only for tenant-key requests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The managed-user header is sent only with the API key it was configured with, never after set_token() or with a per-request token. Identity headers (Authorization, Pine-Managed-User, Pine-Client) are applied after caller headers, as on main, so http.* callers cannot override them. API keys must start with pine_sk_live_ or pine_sk_test_. API_KEY_PREFIXES, validate_external_id, MANAGED_USER_HEADER and CLIENT_HEADER are exported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mint_ticket() always fails with 403 platform_route_not_allowed as AuthError, and while the backend's access switch is off user-scoped calls fail with 403 platform_managed_user_access_disabled as the resource's usual error type. A test pins both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backend now names the managed user by its Pine user ID, not by its external_id. A partner client acts for a managed user with AsyncPineAI(api_key=key, user_id=user.id). The same ID goes in the Pine-Managed-User-Id header, the managed_user_id handshake field and the envelope user_id. - Remove the managed_user parameter and the Pine-Managed-User header. - Check user_id as a canonical decimal string; export validate_user_id. - connect() no longer calls auth.me(). - A key client without user_id can call only the Platform API. - A key client keeps its key: set_token() and auth.verify_code() raise ValueError, checked before the code is sent. - platform.* on a client without api_key raises ValueError. - Add sessions.history() to read stored messages over REST. - Set the version to 0.5.0rc2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rewrite the README Platform API section for user_id, and add docs for partners: the SDK methods (docs/sdk-api.md), the raw HTTP API (docs/http-api.md) and a Socket.IO reference (docs/socketio.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reply can take a few seconds to appear in the REST history, so a reader that sees a new state should read again. Add a short "Answer a form" section: read the fields from the live event or from the history, then pass one answer per field name to submit_form_response(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Charlie-Qilin
marked this pull request as ready for review
October 19, 2026 14:55
The backend only logged Pine-Client: mcp and changed no behavior, so remove client_name and CLIENT_HEADER. Pine's MCP service drops it too. - A Platform key passed as access_token raises ValueError that says to pass it as api_key. - SessionHistoryPage.normalize_cursor uses if/else. - README: rename the section to "Platform API for partners" and drop end_task from the managed-user SessionError list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The transport treated any token that starts with pine_sk_ as a Platform key, so a transport built with token=<key> sent Platform requests and refused set_token(), although no api_key was given. The clients now pass the checked api_key to HttpClient and SyncHttpClient, and the transport uses only that value. A transport built with token= is always a token client; passing both token and api_key raises ValueError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
Pine's B2B Platform API lets a partner (a business that builds on Pine) hold a secret key (
pine_sk_live_...) and create managed users identified by its ownexternal_id. This PR makes the SDK the complete enterprise integration: create the managed user, then act as that user over REST and Socket.IO.This revision follows the current backend contract. The backend now names the managed user by its Pine user ID, not by its
external_id, when a request acts for that user.Public API
AsyncPineAI(api_key=...)/PineAI(api_key=...)is a partner client. Keys must start withpine_sk_live_orpine_sk_test_.client.platform.managed_users.create(external_id, *, email, name, phone=None)is idempotent: 201 on create, 200 with the stored user unchanged afterwards.client.platform.managed_users.get(external_id). Theexternal_idgoes into the path: it is validated first, then percent-encoded.ManagedUser(id,external_id,email,name,phone,created_at) and raisePlatformErrorwith a stablecodeandstatus_code, never the upstream body.Pine-Managed-User-Id.AsyncPineAI(api_key=..., user_id=user.id)/PineAI(api_key=..., user_id=user.id)acts as that managed user.user.idis theidof theManagedUser. The partner stores it in its own user table aftercreate().Authorization: Bearer <key>andPine-Managed-User-Id: <id>.{"token": key, "managed_user_id": "<id>"}. The value is always a JSON string.user_id. One value feeds the header, the handshake and the envelope.connect()sends no REST request first. It no longer callsauth.me().token=.user_idin key mode must be astrin the backend's form: ASCII[1-9][0-9]{0,18}and at most 9223372036854775807. No sign, spaces or leading zeros.intandboolare refused with a message that shows the right form (user_id must be a string such as "123456789"). The check runs when the client is created.validate_user_idis public, so other clients can use the same check.validate_external_idstays forcreate()/get()only.api_key+user_id: act as that managed user.api_keyalone: Platform API only. A user-scoped/v2call (for examplesessions.list(),auth.me(),connect()) raisesValueErrorbefore any request. The message says to passuser_id.api_key+access_token:ValueError.user_idwithoutapi_key: the normal token mode, unchanged.connect(access_token=..., user_id=...)overrides:ValueError.set_token()andauth.verify_code()on it raiseValueError;verify_code()checks before the code is sent. Before,verify_code()silently replaced the key with the user JWT, so later calls acted as the email user, not the managed user.platform.*on a client withoutapi_keyraisesValueErrorbefore any request, not a 401 from the backend.access_tokenraisesValueErrorthat says to pass it asapi_key.api_key, never from the token's text. The clients pass the checkedapi_keyto the transport (HttpClient(api_key=...)/SyncHttpClient(api_key=...)). A transport built withtoken=is always a token client, even if the token starts withpine_sk_. Passing bothtokenandapi_keyto the transport raisesValueError.connect_errorand no code.connect()still waits forreadyuntilready_timeout(15 s by default) and then raisesConnectionError. In key mode the message says to checkapi_keyanduser_id. It does not fail at the first close: python-socketio reconnects after a short network break, and a fail-fast rule would turn that break into a false credentials error.sessions.history(session_id, *, limit=30, before_message_id=None)(async and sync) reads one page of stored messages over REST (GET /v2/sessions/{id}/messages) and returns a typedSessionHistoryPage(SessionHistoryMessage:message_id,type,version,dataas a JSON string,created_at). Before this, the agent's replies were readable only over Socket.IO, so the syncPineAIhad no way to read them. The backend accepts the key plusPine-Managed-User-Idon this route.Authorization,Pine-Managed-User-Id) are applied after an injected client's defaults and after callerheaders=, so neither can override them.API_KEY_PREFIXES,validate_external_id,validate_user_id,MANAGED_USER_HEADER("Pine-Managed-User-Id"),PlatformAPI,ManagedUsersAPI(and their sync variants),ManagedUserandPlatformError.external_idor the user ID.client_nameparameter andCLIENT_HEADER(the backend only logged the valuemcp; Pine's MCP service drops it too), themanaged_userparameter, thePine-Managed-Userheader, themanaged_userhandshake field, and theauth.me()call inconnect(). There is no alias.The README section "Platform API for partners" (renamed from "Integrating as an enterprise (Platform API)") now shows: create once, store
user.id, thenAsyncPineAI(api_key=..., user_id=user.id). It lists the 400 and 404 cases and the refused-handshake behavior. It says which methods raiseSessionErrorand which raisePineAIErroritself (for examplesessions.createandsessions.start_task), and namessessions.start_taskcorrectly.New docs for managed users, linked from the README. They are kept short on purpose, because the API and the SDK are still changing. Examples are not in this PR.
docs/http-api.md(raw HTTP API): how to get a key (support@19pine.ai); one partner's managed users share its credits and limit; create/get a managed user, with a caution that the email and phone must be the end user's own, verified values; the two headers and their errors; the session routes, includingGET /v2/sessions/{id}/messages; how a task runs (it starts on its own, as for a normal Pine user) and what each sessionstatemeans; and how to talk with the agent (Socket.IO recommended, HTTP polling possible).docs/sdk-api.md(Python SDK): the same, as SDK methods.docs/socketio.md(reference only, partial, can change): handshake, envelope, the main events, and how to handle a disconnect: read the history again, or catch up from a revision withsession:messages_sincewhen the join reply hasmax_message_revision.Versioning
This PR sets the version to
0.5.0rc2inpyproject.tomlandsrc/pine_assistant/__init__.py, and the CHANGELOG entry is## [0.5.0rc2] - 2026-10-19(the date is updated at merge). No tag is created, sopublish.ymldoes not run and nothing goes to PyPI. This is the same as0.5.0rc1.Backend contract
What the backend expects:
/api/v2for a managed user:Authorization: Bearer pine_sk_...andPine-Managed-User-Id: <Pine user id>. The headerPine-Managed-User: <external_id>is gone.{"token": key, "managed_user_id": "<Pine user id>"}. The value must be a JSON string. The old fieldmanaged_useris refused. A refused handshake is a server disconnect after connect, with noconnect_errorand no code.idfromPOST /api/platform/v1/managed-users(201/200) orGET /api/platform/v1/managed-users/{external_id}. The backend accepts only a canonical decimal from 1 to 9223372036854775807. The SDK check matches this rule./v2with a key:invalid_request;unauthorized;platform_managed_user_not_found;platform_managed_user_unavailable;platform_access_suspended;platform_route_not_allowed(for exampleauth.mint_ticket());platform_spend_limit_reached, 409platform_session_limit_reached, 409platform_session_state_conflict, 429too_many_requests.platform_managed_user_access_disabledwere removed from the backend. The SDK and README no longer mention them.create/getwithexternal_id, no managed-user header on Platform calls, andPlatformError.Why the backend changed from
external_idto the Pine user ID:/v2route and every Socket.IO envelope already names objects by Pine IDs, for example session IDs and the user ID in envelopes. A partner must hold the Pine user ID anyway.external_id→ user lookup on every request.Dependencies
This SDK version needs a backend that accepts
Pine-Managed-User-Idand themanaged_user_idhandshake field. Merge it only when that backend is live.Tests
tests/test_platform.pyruns against a fake backend on 127.0.0.1 (aiohttp + python-socketio). It covers:Pine-Managed-User-Idwith the ID, and no oldPine-Managed-Userheader;{"token": key, "managed_user_id": "<id>"}with a string value, across a reconnect, and no/auth/merequest fromconnect();user_idequals the ID;ready:connect()raisesConnectionErrorafterready_timeout, with the key-mode hint or the token-mode hint;user_id):sessions.list(),auth.me()andconnect()raiseValueErrorthat says to passuser_id, with no request and no handshake sent; the Platform call still works; the sync client does the same;connect()overrides in key mode raiseValueError;set_token()andauth.verify_code()(async and sync) with no request sent;platform.*on a token client raisesValueErrorwith no request sent (async and sync);validate_user_idrefuses"0","01","+1"," 1","1 ","1\n","","9223372036854775808","١"(Arabic-Indic digit),"usr_1",42,True,None, and accepts"1","123456789"and the boundary"9223372036854775807";external_id/ leading zero / out-of-range /int/boolasuser_id, a Platform key passed asaccess_token), and thatmanaged_user=andclient_name=are gone;invalid_request, 401, 402, 403 (platform_access_suspended,platform_managed_user_unavailable,platform_route_not_allowed), 404platform_managed_user_not_found, 409 (both codes) and 429 onsessions.list();sessions.history(): path, query, typed page, an emptynext_message_idread asNone, extra fields kept, input checks before any request,SessionErrorwithout the upstream body, and the managed-user headers;api_keyonly: a transport built withtoken=<key>is a token client (Platform call refused,set_token()allowed), one built withapi_key=<key>is a key client, and both together are refused;pine_sk_key gets no managed-user header), caller and injected headers that cannot override identity, and the public names.external_idor the user ID. The fake backend puts all three in its error bodies to check this.HOME/PATHand proxies pointed at a dead local port.🤖 Generated with Claude Code