{"openapi":"3.1.0","info":{"title":"Portfolio Assistant API","version":"0.2.0"},"components":{"schemas":{"Transcript":{"type":"object","properties":{"text":{"type":"string"},"seconds":{"type":"number"},"costUsd":{"type":"number"},"model":{"type":"string"}},"required":["text","model"]},"ChatEvent":{"oneOf":[{"type":"object","properties":{"version":{"type":"number","enum":[1]},"type":{"type":"string","enum":["delta"]},"text":{"type":"string"}},"required":["version","type","text"]},{"type":"object","properties":{"version":{"type":"number","enum":[1]},"type":{"type":"string","enum":["tool"]},"id":{"type":"string"},"name":{"type":"string"},"summary":{"type":"string"},"status":{"type":"string","enum":["running","done","error"]},"ms":{"type":"number"}},"required":["version","type","id","name","summary","status"],"description":"A real tool execution: emitted when it starts and again when it finishes."},{"type":"object","properties":{"version":{"type":"number","enum":[1]},"type":{"type":"string","enum":["sources"]},"sources":{"type":"array","items":{"$ref":"#/components/schemas/SourceCitation"}}},"required":["version","type","sources"],"description":"Source evidence read while answering, pinned to the indexed commit."},{"type":"object","properties":{"version":{"type":"number","enum":[1]},"type":{"type":"string","enum":["action"]},"action":{"$ref":"#/components/schemas/UiAction"}},"required":["version","type","action"],"description":"A navigation or highlight the browser should perform, then acknowledge at /v1/actions/{id}."},{"type":"object","properties":{"version":{"type":"number","enum":[1]},"type":{"type":"string","enum":["image"]},"image":{"type":"object","properties":{"id":{"type":"string"},"src":{"type":"string"},"alt":{"type":"string"},"caption":{"type":"string"}},"required":["id","src","alt","caption"]}},"required":["version","type","image"],"description":"One of the portfolio's own screenshots, chosen from a closed list."},{"type":"object","properties":{"version":{"type":"number","enum":[1]},"type":{"type":"string","enum":["draft"]},"draft":{"$ref":"#/components/schemas/ContactDraft"}},"required":["version","type","draft"],"description":"An editable message preview. Nothing is sent until the visitor posts it to /v1/contact."},{"type":"object","properties":{"version":{"type":"number","enum":[1]},"type":{"type":"string","enum":["artifact"]},"artifact":{"$ref":"#/components/schemas/Artifact"}},"required":["version","type","artifact"],"description":"A generated document, previewable inline and downloadable through /v1/artifacts/{id}."},{"type":"object","properties":{"version":{"type":"number","enum":[1]},"type":{"type":"string","enum":["slots"]},"availability":{"$ref":"#/components/schemas/Availability"}},"required":["version","type","availability"],"description":"Real free slots read from the calendar, for the visitor to pick from."},{"type":"object","properties":{"version":{"type":"number","enum":[1]},"type":{"type":"string","enum":["proposal"]},"proposal":{"$ref":"#/components/schemas/BookingProposal"}},"required":["version","type","proposal"],"description":"A call awaiting the visitor's confirmation at /v1/bookings. Nothing is booked yet."},{"type":"object","properties":{"version":{"type":"number","enum":[1]},"type":{"type":"string","enum":["usage"]},"usage":{"$ref":"#/components/schemas/Usage"}},"required":["version","type","usage"],"description":"Measured answer latency and the provider-reported token usage, when the provider reports it."},{"type":"object","properties":{"version":{"type":"number","enum":[1]},"type":{"type":"string","enum":["done"]},"finishReason":{"type":"string","enum":["stop","length"]}},"required":["version","type","finishReason"]},{"type":"object","properties":{"version":{"type":"number","enum":[1]},"type":{"type":"string","enum":["error"]},"message":{"type":"string"}},"required":["version","type","message"]}]},"SourceCitation":{"type":"object","properties":{"repo":{"type":"string"},"path":{"type":"string"},"language":{"type":"string"},"symbols":{"type":"array","items":{"type":"string"}},"startLine":{"type":"number"},"endLine":{"type":"number"},"commit":{"type":"string"},"url":{"type":"string"},"snippet":{"type":"string"}},"required":["repo","path","language","symbols","startLine","endLine","commit","url","snippet"]},"UiAction":{"type":"object","properties":{"id":{"type":"string"},"target":{"type":"string"},"route":{"type":"string"},"anchor":{"type":"string"},"label":{"type":"string"},"action":{"type":"string","enum":["reveal","contact"]}},"required":["id","target","route","anchor","label","action"]},"ContactDraft":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":"string"},"message":{"type":"string"},"to":{"type":"string"}},"required":["id","name","email","message","to"]},"Artifact":{"type":"object","properties":{"id":{"type":"string"},"kind":{"type":"string"},"title":{"type":"string"},"bytes":{"type":"number"},"markdown":{"type":"string"},"expiresAt":{"type":"string"}},"required":["id","kind","title","bytes","markdown","expiresAt"]},"Availability":{"type":"object","properties":{"timeZone":{"type":"string"},"durationMinutes":{"type":"number"},"label":{"type":"string"},"key":{"type":"string"},"slots":{"type":"array","items":{"$ref":"#/components/schemas/Slot"}}},"required":["timeZone","durationMinutes","label","key","slots"]},"Slot":{"type":"object","properties":{"start":{"type":"string"},"end":{"type":"string"}},"required":["start","end"]},"BookingProposal":{"type":"object","properties":{"id":{"type":"string"},"start":{"type":"string"},"end":{"type":"string"},"timeZone":{"type":"string"},"durationMinutes":{"type":"number"},"label":{"type":"string"},"key":{"type":"string"},"name":{"type":"string"},"email":{"type":"string"},"notes":{"type":"string"}},"required":["id","start","end","timeZone","durationMinutes","label","key","name","email","notes"]},"Usage":{"type":"object","properties":{"ms":{"type":"number"},"model":{"type":"string"},"promptTokens":{"type":"number"},"completionTokens":{"type":"number"},"costUsd":{"type":"number"},"cached":{"type":"boolean","description":"True when a stored answer was replayed instead of generated."}},"required":["ms","model"]}},"parameters":{}},"paths":{"/health":{"get":{"responses":{"200":{"description":"Process is running","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]}},"required":["status"]}}}}}}},"/v1/sessions":{"post":{"summary":"Start an anonymous conversation","description":"Returns a bearer token identifying one anonymous conversation. Store it client-side and send it on every /v1 call. Expiry slides forward on each use; after that the session and what it owns are deleted. Conversations are not stored on the server.","responses":{"201":{"description":"Session created","content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string"},"expiresAt":{"type":"string"}},"required":["token","expiresAt"]}}}},"403":{"description":"Disallowed origin","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"429":{"description":"Rate, concurrency or daily budget limit","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"503":{"description":"The bot check could not be reached","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/v1/transcribe":{"post":{"summary":"Transcribe a voice note","description":"Requires the configured site Origin and a session bearer token. Takes one recorded clip as a base64 audio data URL, returns its transcript, and stores nothing: the browser keeps the audio and the text. The transcript is what the visitor then sends to /v1/chat.","parameters":[{"schema":{"type":"string","description":"Bearer <session token>","example":"Bearer abc123"},"required":true,"description":"Bearer <session token>","name":"authorization","in":"header"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"mediaType":{"type":"string","pattern":"^audio\\/[a-z0-9.+-]+(;\\s*codecs=[a-z0-9.,\" -]+)?$/i"},"dataUrl":{"type":"string","maxLength":12000000,"pattern":"^data:audio\\/[a-z0-9.+-]+(;\\s*codecs=[^;,]+)?;base64,[A-Za-z0-9+/=]+$/i"}},"required":["mediaType","dataUrl"],"additionalProperties":false}}}},"responses":{"200":{"description":"The transcript","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Transcript"}}}},"400":{"description":"Invalid request","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing, unknown or expired session","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Disallowed origin","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"413":{"description":"Body too large","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"429":{"description":"Rate, concurrency or daily budget limit","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"502":{"description":"The transcription provider failed","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/v1/chat":{"post":{"summary":"Stream a portfolio answer","description":"Requires the configured site Origin and a session bearer token. The browser keeps the conversation and sends its recent turns as history; the server keeps the newest MAX_MESSAGES_PER_SESSION turns and trims the oldest to fit the context budget. When the full retained history, question, model, prompt, time zone and indexed commits match a finished answer from the last ANSWER_CACHE_TTL_HOURS, that answer is replayed across visitors with usage.cached set, without calling the model or spending the daily budget. Session-bound results are never cached. A voice note is transcribed first at /v1/transcribe and arrives here as text. POST using fetch; SSE data is one JSON ChatEvent per frame, and a done or error event terminates the response. OpenAPI describes each event, not the whole stream.","parameters":[{"schema":{"type":"string","description":"Bearer <session token>","example":"Bearer abc123"},"required":true,"description":"Bearer <session token>","name":"authorization","in":"header"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","minLength":1,"maxLength":8000},"history":{"type":"array","items":{"type":"object","properties":{"role":{"type":"string","enum":["user","assistant"]},"content":{"type":"string","maxLength":24000}},"required":["role","content"],"additionalProperties":false},"maxItems":200,"default":[]}},"required":["message"],"additionalProperties":false}}}},"responses":{"200":{"description":"SSE frames containing versioned ChatEvent JSON","content":{"text/event-stream":{"schema":{"$ref":"#/components/schemas/ChatEvent"}}}},"400":{"description":"Invalid request","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing, unknown or expired session","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Disallowed origin","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"413":{"description":"Body too large","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"429":{"description":"Rate, concurrency or daily budget limit","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/v1/actions/{id}":{"post":{"summary":"Acknowledge a requested UI action","description":"The browser reports what actually happened: done, missing when the target was not on the page, failed otherwise. Only the session that requested the action can acknowledge it, and only once.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer <session token>","example":"Bearer abc123"},"required":true,"description":"Bearer <session token>","name":"authorization","in":"header"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["done","missing","failed"]}},"required":["status"],"additionalProperties":false}}}},"responses":{"204":{"description":"Acknowledged"},"400":{"description":"Invalid request","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing, unknown or expired session","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Disallowed origin","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"No such pending action for this session","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"429":{"description":"Rate, concurrency or daily budget limit","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/v1/contact":{"post":{"summary":"Send a message to Jim","description":"The only path to the email provider, and it is never reached by the model: the visitor posts the exact text they approved. With draftId the draft must belong to the caller's session, and posting the same draft again returns the first result instead of sending twice. Without draftId this is the plain contact form. A 202 means the provider accepted the message, not that it reached the inbox.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"draftId":{"type":"string","format":"uuid"},"name":{"type":"string","minLength":1,"maxLength":120},"email":{"type":"string","maxLength":200,"format":"email"},"message":{"type":"string","minLength":10,"maxLength":4000},"_gotcha":{"type":"string","maxLength":200}},"required":["name","email","message"],"additionalProperties":false}}}},"responses":{"202":{"description":"Accepted by the email provider","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["accepted","already-sent"]}},"required":["id","status"]}}}},"400":{"description":"Invalid message","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"A draft was named but the session is missing or expired","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Disallowed origin","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"No such draft for this session","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"429":{"description":"Send limit reached","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"502":{"description":"The email provider rejected the message; the draft is kept","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"503":{"description":"Email delivery is not configured, or the bot check is unreachable","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/v1/artifacts/{id}":{"get":{"summary":"Get a download link for a generated document","description":"Returns a short-lived signed URL for an artifact belonging to the caller's session. The object itself is private, so the link is the only way to read it and it expires within minutes.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer <session token>","example":"Bearer abc123"},"required":true,"description":"Bearer <session token>","name":"authorization","in":"header"}],"responses":{"200":{"description":"Signed download link","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string"},"title":{"type":"string"},"expiresInSeconds":{"type":"number"}},"required":["url","title","expiresInSeconds"]}}}},"401":{"description":"Missing, unknown or expired session","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Disallowed origin","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"No such artifact for this session","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"429":{"description":"Rate, concurrency or daily budget limit","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"503":{"description":"Artifact storage is not configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/v1/proposals":{"post":{"summary":"Hold a slot for confirmation","description":"Called when the visitor picks a time in the slot picker, so a call can be confirmed without the model proposing one first. The slot is checked against live availability and nothing is booked: it only creates the pending proposal that /v1/bookings confirms.","parameters":[{"schema":{"type":"string","description":"Bearer <session token>","example":"Bearer abc123"},"required":true,"description":"Bearer <session token>","name":"authorization","in":"header"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"start":{"type":"string","format":"date-time"},"key":{"type":"string","minLength":1,"maxLength":16},"timeZone":{"type":"string","minLength":1,"maxLength":64}},"required":["start","key","timeZone"],"additionalProperties":false}}}},"responses":{"201":{"description":"Proposal created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingProposal"}}}},"400":{"description":"Invalid request","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing, unknown or expired session","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Disallowed origin","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"No such meeting length","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"409":{"description":"The slot is no longer free","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"slots":{"type":"array","items":{"$ref":"#/components/schemas/Slot"}}},"required":["error","slots"]}}}},"429":{"description":"Rate, concurrency or daily budget limit","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"502":{"description":"The calendar could not be reached","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"503":{"description":"Scheduling is not configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/v1/bookings":{"post":{"summary":"Confirm a call","description":"The only path to the calendar provider, and the model never reaches it: the visitor confirms the slot and their own details. The slot is re-checked against live availability first, so a time taken in the meantime returns 409 with the current slots. Confirming the same proposal twice returns the first booking. If the provider does not answer, the proposal is left unresolved rather than retried, because the booking may already exist.","parameters":[{"schema":{"type":"string","description":"Bearer <session token>","example":"Bearer abc123"},"required":true,"description":"Bearer <session token>","name":"authorization","in":"header"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"proposalId":{"type":"string","format":"uuid"},"start":{"type":"string","format":"date-time"},"name":{"type":"string","minLength":1,"maxLength":120},"email":{"type":"string","maxLength":200,"format":"email"},"timeZone":{"type":"string","minLength":1,"maxLength":64}},"required":["proposalId","start","name","email","timeZone"],"additionalProperties":false}}}},"responses":{"201":{"description":"Booked with the provider","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"uid":{"type":"string"},"start":{"type":"string"},"status":{"type":"string","enum":["confirmed","already-booked"]}},"required":["id","uid","start","status"]}}}},"400":{"description":"Invalid request","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing, unknown or expired session","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Disallowed origin","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"No such proposal for this session","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"409":{"description":"The slot is no longer free","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"slots":{"type":"array","items":{"$ref":"#/components/schemas/Slot"}}},"required":["error","slots"]}}}},"429":{"description":"Rate, concurrency or daily budget limit","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"502":{"description":"The provider rejected the booking","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"503":{"description":"Scheduling is not configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"504":{"description":"The provider did not answer; the booking may exist","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}}}}