{"openapi":"3.1.0","info":{"title":"Parlel API","description":"The full Parlel API, signed-in operations included. If you are here without an account, you want the anonymous subset at `/api/public/openapi.json`, and the guide at https://parlel.com/search.md.","version":"1.0.0"},"paths":{"/health":{"get":{"summary":"Health","description":"Liveness, and the two facts you need when something looks wrong.\n\n``database`` names the host and database this process is actually connected\nto -- host and name only, never credentials. It is here rather than behind\nauth because the question it answers (\"is prod pointed at the right\ndatabase?\") is one you ask when you cannot get in, and because the answer\nreveals nothing a port scan would not.\n\nAdded after production spent five hours on a stale database with a perfectly\nhealthy ``/health``.","operationId":"health_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Health Health Get"}}}}}}},"/api/agents":{"get":{"tags":["agents"],"summary":"List Agents","operationId":"list_agents_api_agents_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","additionalProperties":true},"title":"Response List Agents Api Agents Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["agents"],"summary":"Create Agent","operationId":"create_agent_api_agents_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Create Agent Api Agents Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/usage":{"get":{"tags":["agents"],"summary":"Get Usage","operationId":"get_usage_api_agents_usage_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Usage Api Agents Usage Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/email":{"get":{"tags":["agents"],"summary":"Get Email Prefs","operationId":"get_email_prefs_api_agents_email_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Email Prefs Api Agents Email Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["agents"],"summary":"Set Email Prefs","operationId":"set_email_prefs_api_agents_email_put","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Set Email Prefs Api Agents Email Put"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["agents"],"summary":"Set Digest Prefs","description":"Update the account's delivery shape. Returns the same payload as ``GET``.\n\n``PATCH`` rather than a second ``PUT``, and the split from ``PUT /agents/email``\nis deliberate: that one changes the ADDRESS, which resets verification and sends\nmail. This one changes how much mail arrives at an already-verified address and\nmust never do either of those things — a user toggling a checkbox has not asked\nto re-confirm their email.\n\nA cadence or hour change re-anchors ``next_combined_digest_at`` to the next\nALIGNED boundary from now (``scheduler.aligned_digest_at``), so somebody who\nswitches to hourly at 12:40 gets their first hourly email at 13:00 rather than\nat 13:40 and every :40 thereafter.\n\n``combined_digest`` is accepted and ignored. The account digest is the only\ndigest, so it is always on; the column is pinned TRUE here so a stale FALSE\nleft by an older client can never make the scheduler skip the account.","operationId":"set_digest_prefs_api_agents_email_patch","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DigestPrefsUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Set Digest Prefs Api Agents Email Patch"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/tour-complete/{guide}":{"post":{"tags":["agents"],"summary":"Complete Tour","description":"Mark one onboarding guide as seen. Idempotent.\n\nOne endpoint for both finishing and skipping, because the product does not\ncare which happened: the only decision this state drives is \"show it again?\",\nand after either the answer is no. Recording them separately would be data\nnobody is going to act on.\n\n404 on an unknown guide rather than a silent 200. A typo in a guide name would\notherwise return success while recording nothing, and the symptom — a tour that\nreappears on every page load forever — points at the client, not at here.\n\nThe timestamp is only written once. A user who replays a guide later has not\nbecome newer, and overwriting it would lose the one fact worth keeping: when\nthey first met the product.","operationId":"complete_tour_api_agents_tour_complete__guide__post","parameters":[{"name":"guide","in":"path","required":true,"schema":{"type":"string","title":"Guide"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Complete Tour Api Agents Tour Complete  Guide  Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/account/kind":{"post":{"tags":["agents"],"summary":"Set Account Kind","description":"Say whether this account is a person or a company. Write-once-ish.\n\n## Why it is not a settings toggle\n\nThe kind decides which public page an account owns: an individual gets a\n``user_profiles`` row at ``/u/{handle}``, a company gets a ``companies`` row at\n``/c/{handle}``. Switching therefore means abandoning one published page and\nbuilding another -- the old handle stays claimed by a row nothing in the new\nshape reads, links to it keep resolving to a page the account is no longer\nmaintaining, and any roles hanging off a company become orphans of a company\nthe account has stopped being. That is a support conversation, not a radio\nbutton, and every automatic resolution of it (delete the old page, migrate the\nhandle, keep both) is a decision this endpoint has no business making on\nsomebody's behalf.\n\n## So the rule is: freely, until it has been used\n\nSettable while the account has neither a profile nor a company row, which is\nthe whole of onboarding -- a user can pick \"company\", change their mind, and\npick \"individual\" as many times as they like before saving anything. The first\nsave of either page fixes it, and after that this returns 409 with a sentence\nsaying why.\n\nSetting it to the value it already holds is always a 200 and writes nothing,\nincluding after a page exists. A client that resends its current state on\nevery settings load must not be told it has a conflict with itself, and\n\"confirm what is already true\" is not the operation this endpoint is\nprotecting against.\n\n## Query cost\n\nThe two existence probes only run when the value is actually changing, so the\ncommon no-op path costs one plan read and nothing else. Profiles are keyed by\n``user_id``; companies are looked up by owner (``any_company_for``), since a\ncompany row's primary key is now ``id``.","operationId":"set_account_kind_api_account_kind_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountKindUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Set Account Kind Api Account Kind Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/email/resend":{"post":{"tags":["agents"],"summary":"Resend Verification","operationId":"resend_verification_api_agents_email_resend_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Resend Verification Api Agents Email Resend Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/{agent_id}":{"get":{"tags":["agents"],"summary":"Get Agent","operationId":"get_agent_api_agents__agent_id__get","parameters":[{"name":"agent_id","in":"path","required":true,"schema":{"type":"string","title":"Agent Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Agent Api Agents  Agent Id  Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["agents"],"summary":"Update Agent","operationId":"update_agent_api_agents__agent_id__patch","parameters":[{"name":"agent_id","in":"path","required":true,"schema":{"type":"string","title":"Agent Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Update Agent Api Agents  Agent Id  Patch"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["agents"],"summary":"Delete Agent","operationId":"delete_agent_api_agents__agent_id__delete","parameters":[{"name":"agent_id","in":"path","required":true,"schema":{"type":"string","title":"Agent Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/{agent_id}/run-now":{"post":{"tags":["agents"],"summary":"Run Now","operationId":"run_now_api_agents__agent_id__run_now_post","parameters":[{"name":"agent_id","in":"path","required":true,"schema":{"type":"string","title":"Agent Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Run Now Api Agents  Agent Id  Run Now Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/{agent_id}/runs":{"get":{"tags":["agents"],"summary":"List Runs","operationId":"list_runs_api_agents__agent_id__runs_get","parameters":[{"name":"agent_id","in":"path","required":true,"schema":{"type":"string","title":"Agent Id"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":20,"title":"Limit"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","additionalProperties":true},"title":"Response List Runs Api Agents  Agent Id  Runs Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/{agent_id}/searches":{"get":{"tags":["agents"],"summary":"List Agent Searches","description":"What this agent searched: the queries, and the domains it was held to.\n\nScoped to one run with ``run_id``, otherwise the agent's most recent calls\nacross all runs. Ordered newest first so the default page answers \"what did\nit just do?\" without paging.","operationId":"list_agent_searches_api_agents__agent_id__searches_get","parameters":[{"name":"agent_id","in":"path","required":true,"schema":{"type":"string","title":"Agent Id"}},{"name":"run_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Run Id"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":50,"title":"Limit"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Agent Searches Api Agents  Agent Id  Searches Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/{agent_id}/findings":{"get":{"tags":["agents"],"summary":"List Agent Findings","operationId":"list_agent_findings_api_agents__agent_id__findings_get","parameters":[{"name":"agent_id","in":"path","required":true,"schema":{"type":"string","title":"Agent Id"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":50,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Agent Findings Api Agents  Agent Id  Findings Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/findings":{"get":{"tags":["agents"],"summary":"List All Findings","operationId":"list_all_findings_api_findings_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":50,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List All Findings Api Findings Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/findings/{finding_id}":{"get":{"tags":["agents"],"summary":"Get Finding","description":"One finding the viewer may see, in the same shape the feed card renders.\n\nDigest Apply via Parlel links land on ``/feed?apply=<id>``. That finding is\noften not on the first page of ``/feed`` (newest-first, and the email can\nbe days old), so the feed fetches this row and pins it at the top instead\nof hoping the reader scrolls far enough to recognise it.","operationId":"get_finding_api_findings__finding_id__get","parameters":[{"name":"finding_id","in":"path","required":true,"schema":{"type":"string","title":"Finding Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Finding Api Findings  Finding Id  Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feed":{"get":{"tags":["agents"],"summary":"Get Feed","description":"Merged, newest-first timeline of findings and research runs.\n\nFindings come from two places: agents the viewer OWNS, and agents the viewer\nFOLLOWS. Runs come from one — their own. See ``## Research is own-only`` below.\n\n## Why two selects merged in Python rather than one ``UNION ALL``\n\nThe two halves are not the same query wearing different filters. The followed\nhalf joins two more tables (``agents`` for the published check and the slug,\n``agent_follows`` for the subscription), holds its rows to a different\nrelevance floor, and serialises through a different allowlist that carries an\nowner byline the own half has no column for. A ``UNION ALL`` would have to\nwiden both branches to one column list — NULL slug, NULL owner id on every own\nrow — to get an outer ``ORDER BY``, and would then hand back rows the\nserialisers cannot tell apart without re-deriving the discriminator they were\nunited to erase.\n\nMerging in Python needs none of that, and it is the pattern this handler is\nalready built on: findings and runs are two tables merged exactly this way, by\nthe same keyset predicate and the same over-fetch. Following it makes the new\nsource one more participant in a merge that is already correct, rather than a\nsecond, different pagination scheme living next to it.\n\n## How pagination stays exact across the union\n\nEvery source applies the SAME ``(created_at, id)`` keyset predicate\n(``_before_cursor``), orders by the same tuple descending, and over-fetches\n``limit + 1``. The merged list is sorted on that tuple and cut at ``limit``,\nand the cursor is the tuple of the last row served.\n\nThat is enough for exactness, and the argument is worth writing down because a\nfeed that silently drops a row at a page boundary is the worst failure mode\nthis endpoint has. Every row a source withheld is strictly older (by the\ntuple) than the ``limit + 1`` it returned, so it is also strictly older than\nthe page's last item — which means the next request, whose predicate is\n\"strictly older than that item\", is guaranteed to reach it. No row can be\nserved twice, because the predicate is strict on a total order: ids are unique\nwithin a table and carry distinct prefixes (``fnd_`` / ``run_``) across them.\nAnd no row can be lost, because nothing is ever excluded by a per-source\nboundary — only by the merged one.\n\nThe one row a follower could otherwise see twice is a finding from an agent\nthey both own and follow. That is unreachable through the API (following your\nown agent is a 400) but a legacy self-follow row could exist, so the followed\nhalf excludes the viewer's own agents outright rather than relying on it.\n\n## Research is own-only, deliberately\n\n``AgentRun`` is NOT unioned. A run is the owner's operational detail — which\nqueries were issued, how many pages were examined, which ones were rejected\nand why, what the inference cost — and the public agent page withholds all of\nit. A follower subscribed to somebody's *findings*, not to their working\nnotes, and ``include_research=true`` must not turn a subscription into an\naudit trail of another person's account. So     ``AgentRun.user_id == user_id``\nstays exactly as it was: the asymmetry between the two halves is the rule, not\na filter somebody forgot to relax.\n\n## ``kind`` narrows which sources run at all\n\n``kind`` is not a post-filter over the merged list. Each value switches whole\nsources off before they are queried, so ``kind=posts`` costs one statement\nrather than four and the pagination stays exact by the same argument as above\n-- a source that contributed nothing withheld nothing.\n\nIt composes with ``scope`` rather than duplicating it. ``scope`` asks whose\ncontent; ``kind`` asks what kind. The one combination that cannot mean\nanything is ``kind=posts`` with a ``scope``/``agent_id`` narrowing, because\nboth of those are questions about agents and a post has no agent -- that is\nresolved the same way it already was for the unfiltered feed, by declining to\nserve posts alongside an agent-shaped narrowing.\n\n## Two ways to floor findings, and the default is the agent's own\n\n``at_agent_bar=true`` holds every finding to the ``relevance_threshold`` of\nthe agent that produced it. ``min_relevance`` holds them all to one number.\n\nThe first is the honest default and the second is the override, because the\nviewer already chose a bar per agent when they wrote the brief, and a second\nglobal number can only disagree with it. It did: the feed's flat default was\n90 against an agent default of 60, so findings were emailed and then hidden\nfrom the page the email linked to.\n\n``include_unscored=true`` additionally admits rows the scorer never graded,\nwhich no floor can reach. It is a separate switch and not a floor of 0\nbecause relevance 0 is overwhelmingly a real verdict: on production 11,956 of\nthe 11,971 zero-relevance findings were scored 0 deliberately -- \"wrong type\nof thing, or unrelated\" -- and carry a summary saying so. Only 15 were\ngenuinely unjudged. A 0 floor would surface twelve thousand rejects to reach\nfifteen findings; ``scored IS false`` reaches exactly the fifteen.\n\nFollowed agents are exempt from all of it: they keep ``MIN_PUBLIC_RELEVANCE``\nparity with ``/a/{slug}``.","operationId":"get_feed_api_feed_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":30,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"agent_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"}},{"name":"min_relevance","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":0,"default":1,"title":"Min Relevance"}},{"name":"include_research","in":"query","required":false,"schema":{"type":"boolean","default":true,"title":"Include Research"}},{"name":"scope","in":"query","required":false,"schema":{"enum":["all","own","followed"],"type":"string","default":"all","title":"Scope"}},{"name":"kind","in":"query","required":false,"schema":{"enum":["all","posts","findings"],"type":"string","default":"all","title":"Kind"}},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Q"}},{"name":"at_agent_bar","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"At Agent Bar"}},{"name":"include_unscored","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Unscored"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Feed Api Feed Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/verify-email/{token}":{"get":{"tags":["agents"],"summary":"Verify Email","operationId":"verify_email_api_agents_verify_email__token__get","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/unsubscribe/{token}":{"post":{"tags":["agents"],"summary":"Unsubscribe One Click","description":"RFC 8058 one-click unsubscribe target.\n\nMail providers POST here directly, with no cookies and no user interaction,\nso it must be unauthenticated and must succeed on the first attempt.\nIdempotent by design: providers retry, and a second POST must not error.","operationId":"unsubscribe_one_click_api_agents_unsubscribe__token__post","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Unsubscribe One Click Api Agents Unsubscribe  Token  Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["agents"],"summary":"Unsubscribe Link","description":"Human-clickable counterpart to the one-click POST above.","operationId":"unsubscribe_link_api_agents_unsubscribe__token__get","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/apply/prefill":{"get":{"tags":["apply"],"summary":"Prefill","operationId":"prefill_api_apply_prefill_get","parameters":[{"name":"finding_id","in":"query","required":true,"schema":{"type":"string","title":"Finding Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Prefill Api Apply Prefill Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/apply/sessions":{"post":{"tags":["apply"],"summary":"Start Session","operationId":"start_session_api_apply_sessions_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_start_session_api_apply_sessions_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Start Session Api Apply Sessions Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/apply/sessions/{session_id}":{"get":{"tags":["apply"],"summary":"Get Session","operationId":"get_session_api_apply_sessions__session_id__get","parameters":[{"name":"session_id","in":"path","required":true,"schema":{"type":"string","title":"Session Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Session Api Apply Sessions  Session Id  Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["apply"],"summary":"Close Session","operationId":"close_session_api_apply_sessions__session_id__delete","parameters":[{"name":"session_id","in":"path","required":true,"schema":{"type":"string","title":"Session Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/apply/applications":{"post":{"tags":["apply"],"summary":"Mark Applied","operationId":"mark_applied_api_apply_applications_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkApplied"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Mark Applied Api Apply Applications Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/models":{"get":{"tags":["models"],"summary":"List Models","operationId":"list_models_api_models_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Models Api Models Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/models/default":{"put":{"tags":["models"],"summary":"Set Default Model","operationId":"set_default_model_api_models_default_put","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DefaultTierUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Set Default Model Api Models Default Put"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/reports":{"get":{"tags":["reports"],"summary":"List Reports","description":"The caller's reports, newest first, one page at a time.\n\n## ``agent_id``\n\nNarrows the list to the reports cut from one agent, which is how the agent\npage's Reports tab reads. Optional, and its absence means \"everything I\nown\" -- the two callers want genuinely different lists rather than one being\na special case of the other.\n\nFiltered in SQL rather than by fetching a page and dropping the rows that do\nnot match. That distinction is not premature: this list is keyset-paged, so a\nclient-side filter would return a *page* of twelve mixed reports and then\nshow however many of them happened to belong to the agent, which reads as\n\"this agent has three reports\" when it has thirty. The predicate has to be\ninside the window, not outside it.\n\nAn unknown or unowned ``agent_id`` is not an error. It returns an empty page,\nbecause ``user_id`` is already in the WHERE clause and no row can escape it:\nasking about somebody else's agent is answered with \"you have no reports from\nthat\", which is true, and which leaks nothing about whether the agent exists.\n\nNo index is added for this. ``ix_agent_reports_agent_id`` already exists on\nthe column, and the account-scoped row count here is in the tens.\n\n## Why this grew a cursor\n\nIt used to select every report the account owned, with no limit and no\ncursor, and render all of them. That is fine at nine reports and it is a page\nthat gets slower every time somebody uses the feature -- the failure mode is\nnot a crash, it is a screen that takes a little longer to open each month\nuntil somebody calls it broken.\n\n## Keyset, not offset\n\nSame shape as the two public directories: order by ``(created_at DESC, id\nDESC)`` and resume from a cursor naming a position in that order. ``id`` is\nthe tie-break, so the order is total and a page boundary cannot sit in the\nmiddle of a group of reports created in the same second -- which is exactly\nwhat happens when somebody generates two from the same agent.\n\nOffset paging would re-read and skip rows, and would also drop a report from\npage two the moment a new one is written between requests. A cursor names a\nrow, so a report arriving while the reader pages cannot shuffle the page they\nhave not read yet.\n\n## The shape changed\n\nThis used to return a bare array. It now returns ``{items, next_cursor}``,\nmatching the directories, because a list with no envelope has nowhere to put\n\"there is more\". `next_cursor` is `null` on the last page.","operationId":"list_reports_api_reports_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":60,"minimum":1,"default":12,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"agent_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Reports Api Reports Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["reports"],"summary":"Create Report","operationId":"create_report_api_reports_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Create Report Api Reports Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/reports/{report_id}":{"get":{"tags":["reports"],"summary":"Get Report","operationId":"get_report_api_reports__report_id__get","parameters":[{"name":"report_id","in":"path","required":true,"schema":{"type":"string","title":"Report Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Report Api Reports  Report Id  Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["reports"],"summary":"Update Report","operationId":"update_report_api_reports__report_id__patch","parameters":[{"name":"report_id","in":"path","required":true,"schema":{"type":"string","title":"Report Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Update Report Api Reports  Report Id  Patch"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["reports"],"summary":"Delete Report","operationId":"delete_report_api_reports__report_id__delete","parameters":[{"name":"report_id","in":"path","required":true,"schema":{"type":"string","title":"Report Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/reports/{report_id}/share":{"post":{"tags":["reports"],"summary":"Share Report","description":"Mint a share token, or return the existing one.\n\nIdempotent while shared: a second press must not invalidate a link that is\nalready in someone's inbox. Sharing again AFTER a revoke does mint a new\ntoken, which is the point of revoking.","operationId":"share_report_api_reports__report_id__share_post","parameters":[{"name":"report_id","in":"path","required":true,"schema":{"type":"string","title":"Report Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Share Report Api Reports  Report Id  Share Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["reports"],"summary":"Unshare Report","description":"Take the public page offline immediately by dropping the token.\n\n``shared_at`` is cleared with it: it means \"public since\", and leaving a date\non an unshared report would read as still-published in the list.","operationId":"unshare_report_api_reports__report_id__share_delete","parameters":[{"name":"report_id","in":"path","required":true,"schema":{"type":"string","title":"Report Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Unshare Report Api Reports  Report Id  Share Delete"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/reports/{share_token}":{"get":{"tags":["reports"],"summary":"Get Public Report","description":"Render a shared report for anyone holding the token.\n\nNo auth by necessity, not by convenience: X and LinkedIn fetch this URL from\ntheir own infrastructure to build a preview card, with no session and no\nreferer. The token is the credential.","operationId":"get_public_report_api_public_reports__share_token__get","parameters":[{"name":"share_token","in":"path","required":true,"schema":{"type":"string","title":"Share Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Public Report Api Public Reports  Share Token  Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/{agent_id}/publish":{"get":{"tags":["public"],"summary":"Get Publish State","description":"Current share state for an agent the caller owns.\n\nExists so the publish control can render the truth (and the consent copy's\nfinding count) without the owner having to publish to find out.","operationId":"get_publish_state_api_agents__agent_id__publish_get","parameters":[{"name":"agent_id","in":"path","required":true,"schema":{"type":"string","title":"Agent Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Publish State Api Agents  Agent Id  Publish Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["public"],"summary":"Publish Agent","description":"Mint a public slug for an agent the caller owns.\n\nIdempotent. Re-publishing an already-public agent returns the existing slug\nand leaves ``published_at`` alone: that timestamp is the publication date\nshown on the page, and churning it on every POST would make the page claim\nit was published today every time the owner reopened the dialog.","operationId":"publish_agent_api_agents__agent_id__publish_post","parameters":[{"name":"agent_id","in":"path","required":true,"schema":{"type":"string","title":"Agent Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Publish Agent Api Agents  Agent Id  Publish Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["public"],"summary":"Unpublish Agent","description":"Take the public page offline immediately.\n\nNulls the slug, which is what every public read filters on, so the page and\nevery receipt derived from it 404 from the next request onward. ``clone_count``\nsurvives (those clones still exist and still point here) and so does\n``published_at``, so re-publishing does not rewrite history — though it does\nmint a *new* slug, because the old URL was withdrawn and handing it back out\nwould resurrect links the owner deliberately broke.\n\nFollowers survive too, dormant, and one read does NOT filter them out:\n``GET /api/follows`` still lists this agent to the people who follow it, by\nname only, flagged ``dormant`` (see ``routes/follows.py``). The brief and the\nfindings stay behind the boundary this endpoint just closed — a follower is\ntold their subscription has gone quiet, not what it used to watch.","operationId":"unpublish_agent_api_agents__agent_id__publish_delete","parameters":[{"name":"agent_id","in":"path","required":true,"schema":{"type":"string","title":"Agent Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Unpublish Agent Api Agents  Agent Id  Publish Delete"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/agents":{"get":{"tags":["public"],"summary":"List Public Agents","description":"The gallery: published agents, newest first or most-followed first.\n\nKeyset paginated rather than OFFSET, so a page published mid-scroll cannot\nmake the reader see a duplicate or skip a card.\n\n``sort=popular`` orders by ``(follower_count DESC, published_at DESC, id\nDESC)``. Followers rather than views or clones because followers are the only\ncounter here that costs the person casting the vote something ongoing — a\nview is a bot with a URL and a clone is a single click someone may regret,\nbut a follow is a standing agreement to keep receiving this agent's mail, and\nit is revocable, so a brief that stops being useful loses its ranking instead\nof keeping it forever.\n\nNothing weak can ride that ordering up:\n\n  * Unpublished agents are excluded by the same ``public_slug IS NOT NULL``\n    filter both sorts share, so popularity cannot resurrect a withdrawn page.\n  * An agent with nothing to show has no followers, so it sorts below every\n    followed agent and then falls back to recency — which is exactly where\n    the default ordering would have put it. Popularity demotes the empty\n    gallery card; it never promotes it.\n  * The two ways to manufacture a follower count are both closed off\n    elsewhere: you cannot follow your own agent (``routes/follows.py``\n    returns 400), and one account can only spend its follows once —\n    ``MAX_FOLLOWS`` of them — across the whole gallery.","operationId":"list_public_agents_api_public_agents_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":24,"minimum":1,"default":12,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"sort","in":"query","required":false,"schema":{"type":"string","pattern":"^(recent|popular)$","default":"recent","title":"Sort"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Public Agents Api Public Agents Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/agents/{slug}":{"get":{"tags":["public"],"summary":"Get Public Agent","description":"One public agent: the brief, the counters, and its recent real findings.","operationId":"get_public_agent_api_public_agents__slug__get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Public Agent Api Public Agents  Slug  Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/findings/{finding_id}/receipt":{"get":{"tags":["public"],"summary":"Get Finding Receipt","description":"The shareable \"we were first\" card for one finding.\n\nOnly served while the finding's agent is published — see the module\ndocstring. Findings that never cleared the relevance bar are excluded too:\nthey are dedupe-ledger rows, not claims we want to stand behind.","operationId":"get_finding_receipt_api_public_findings__finding_id__receipt_get","parameters":[{"name":"finding_id","in":"path","required":true,"schema":{"type":"string","title":"Finding Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Finding Receipt Api Public Findings  Finding Id  Receipt Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/agents/{slug}/clone":{"post":{"tags":["public"],"summary":"Clone Public Agent","description":"Copy a published agent into the caller's account and start it running.\n\nCopies the brief and the knobs; copies nothing that belongs to the original\nowner. No findings, no runs, no ``plan_json`` — a clone re-plans from the\nbrief on its first run, which is also what keeps a stale cached plan from\npropagating to every copy.\n\nTwo plan clamps instead of two refusals: the cadence steps down to what the\ncloner can run, and the model tier steps down to what they can fund. The one\nhard limit is the agent cap, because that is a standing claim on the shared\nsearch and inference budget rather than a preference.","operationId":"clone_public_agent_api_public_agents__slug__clone_post","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Clone Public Agent Api Public Agents  Slug  Clone Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/agents/{slug}/follow":{"post":{"tags":["follows"],"summary":"Follow Agent","description":"Subscribe to a published agent's digest. Safe to send twice.\n\nThe insert is attempted rather than guarded. A ``SELECT`` first would look\ntidier and would be wrong: between the read and the write another request\nfrom the same double-tapped button can insert, and the second insert then\neither 500s on the unique index or — if the counter were bumped\noptimistically — leaves ``follower_count`` one ahead of the rows forever. So\nthe unique constraint is the arbiter and ``IntegrityError`` is the expected,\nhandled outcome of the race, not an error path.\n\nThe counter moves in the same transaction as the row and only on the branch\nthat actually created one, so the rollback that follows a lost race takes the\nincrement with it.","operationId":"follow_agent_api_public_agents__slug__follow_post","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Follow Agent Api Public Agents  Slug  Follow Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["follows"],"summary":"Unfollow Agent","description":"Stop receiving a published agent's digest. Safe to send twice.\n\nUnfollowing something you do not follow is a 200 with ``following: false``,\nnot a 404. The caller asked for a state, not for a row to be destroyed, and\nthat state is what they get — which also means a retried DELETE after a\ntimeout cannot report failure for work that already succeeded.\n\n``rowcount`` gates the decrement. The delete and the counter therefore move\ntogether or not at all, and no sequence of requests can push the count below\nthe number of rows behind it.\n\nOnly reaches a *published* agent, because the slug is how it finds one. A\nfollow whose agent has since been unpublished is cancelled through\n``delete_follow`` below, which is keyed on the follow row instead — that\nroute exists because this one cannot be made to work without a slug.","operationId":"unfollow_agent_api_public_agents__slug__follow_delete","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Unfollow Agent Api Public Agents  Slug  Follow Delete"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/agents/unfollow/{token}":{"post":{"tags":["follows"],"summary":"Unfollow One Click","description":"RFC 8058 one-click unfollow target for a follower's digest.\n\n## Why this route is load-bearing\n\n``mailer.unfollow_url_for`` has been putting this URL in the\n``List-Unsubscribe`` header of every follower digest, and the route did not\nexist. Every follower digest therefore shipped a one-click opt-out that\n404s. That is worse than shipping none: Gmail and Yahoo both treat a broken\n``List-Unsubscribe`` as a negative sender signal, and a reader who clicks\n\"unsubscribe\", sees a failure, and then reaches for \"report spam\" costs the\nsending domain far more than the one subscription would have.\n\n## Why it is scoped rather than a full unsubscribe\n\nA follower who opts out of one public agent means \"stop sending me this\nagent\", not \"stop sending me the digests of the agents I built myself\".\n``/api/agents/unsubscribe/{token}`` does the second by setting\n``unsubscribed_at`` on the whole plan. Doing that to somebody who only\nwanted to leave one agent silently kills their own agents' mail too, which\nis a support ticket at best. The token here names a single\n``(agent_id, user_id)`` pair and this route deletes exactly that row.\n\n## Why it always returns 200\n\nBorrowed wholesale from ``unsubscribe_one_click``: mail providers POST here\nwith no cookies, no session and no user interaction, they retry on anything\nthat is not a success, and some flag a sender whose opt-out endpoint 4xxs as\nhaving a broken unsubscribe. A garbage, forged or stale token is therefore\naccepted quietly rather than rejected loudly. Idempotent for the same\nreason — a retry after a successful delete is normal traffic, not an error.\n\n## Why the token is verified even though failure is silent\n\nThe token carries the ``user_id`` it acts on, so an unverified token is an\ninvitation to unsubscribe strangers by editing a URL. The MAC key is that\nuser's own ``unsubscribe_token``, which is why the row has to be read before\nanything can be checked: ``read_unfollow_token`` decodes the claim, and\nnothing is mutated until ``verify_unfollow_token`` confirms it.","operationId":"unfollow_one_click_api_agents_unfollow__token__post","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Unfollow One Click Api Agents Unfollow  Token  Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["follows"],"summary":"Unfollow Link","description":"Human-clickable counterpart to the one-click POST above.\n\nThe visible \"Stop following this agent\" link in the follower footer is a\nplain ``<a href>``, and a mail client issues a GET for it. Without this, that\nlink 404s in a browser tab while the machine-readable header works, which is\nthe more visible half of the same bug.\n\nRedirects to the SPA rather than rendering a page: the API serves no HTML,\nand a bare JSON body is a confusing thing to land on after clicking a link\nin an email.\n\nLands on ``/explore`` rather than ``/discover`` because the person clicking\nis very often not signed in — they were reading mail, not using the app —\nand ``/explore`` is the anonymous door to the same gallery, which redirects\nto ``/discover`` by itself for anyone who does have a session. ``/a`` is not\na route (only ``/a/{slug}`` is), so sending them there would 404.","operationId":"unfollow_link_api_agents_unfollow__token__get","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/follows/{follow_id}":{"delete":{"tags":["follows"],"summary":"Delete Follow","description":"Cancel one of your own subscriptions by its row id. Safe to send twice.\n\n## Why this exists at all\n\n``DELETE /api/public/agents/{slug}/follow`` resolves the agent through\n``_published_agent``, so an unpublished agent — which has no slug, since\nunpublish nulls it — answers 404 there. Verified against a running server:\nonce the owner withdrew the page, the follower's DELETE on the slug they\nfollowed at returned ``{\"detail\": \"no public agent at this link\"}`` and no\nother request could reach the row. The follower was trapped holding a\nsubscription they could not see (it was filtered out of ``GET /api/follows``)\nand could not delete, while their now-phantom follower kept inflating the\nnumber ``sort=popular`` ranks the gallery on.\n\nSo this route is keyed on the *follow row* rather than on the agent's public\nlink, which is the only identifier that survives the agent going private. It\nis scoped by ``user_id``, so a follow id is a capability over exactly one\nrow: your own.\n\n## Why it is not restricted to dormant follows\n\nIt would be easy to 404 here when the agent is still published and tell the\ncaller to use the slug route. That would reintroduce the trap through a race:\nif the owner re-publishes between the follower loading their list and\npressing Unfollow, a dormant-only endpoint would refuse the one request the\nclient is holding an id for. A subscription is the follower's to cancel in\nevery state of the agent, so this accepts both.\n\n## The response\n\n``{\"following\": false}`` and deliberately no ``follower_count``. That number\nis the withdrawn page's social proof and ``_dormant_follow`` withholds it on\npurpose; echoing it out of this handler would be a hole in that decision for\nno gain, since nothing renders it — the card is on its way out. The counter\nis still maintained in the database, gated on ``rowcount`` exactly as the\nslug route does it, so no sequence of double-taps can push it below the rows\nbehind it.\n\nA row that is missing — already deleted, or never this caller's — is a 200,\nnot a 404, for the same reason the slug route is idempotent: the caller asked\nfor a state, not for a row to be destroyed. It also matters that the two are\nindistinguishable, so this cannot be used to probe whether a follow id\nexists.","operationId":"delete_follow_api_follows__follow_id__delete","parameters":[{"name":"follow_id","in":"path","required":true,"schema":{"type":"string","title":"Follow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Follow Api Follows  Follow Id  Delete"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/follows":{"get":{"tags":["follows"],"summary":"List Follows","description":"Everything this user follows: live agents first, then dormant ones.\n\nA live entry is the *same* summary shape as the gallery, so the frontend\nrenders both surfaces through one component. ``following`` is ``true`` on\nevery one by definition, which is why no per-row lookup happens here.\n\n``owned`` is carried too, and it is not dead weight even though the follow\nrule makes it ``false`` on every row you could have followed today. It is a\nproperty of the SHAPE, not of this list: the same card component reads it on\nall three surfaces, so a row here that omitted it would be the one payload\nwhere the client's \"always present\" rule did not hold. It is also not\npermanently false — a self-follow that predates the 400, or a future transfer\nof ownership, would surface here rather than rendering a Follow button on\nyour own agent. Free either way: ``user_id`` is on the row already.\n\nA dormant entry — the agent was unpublished after being followed — is a\nsmaller, separate payload flagged ``dormant: true``. It used to be dropped\nfrom this list entirely, which told the follower they followed nothing; see\nthe module docstring, and ``_dormant_follow`` for what it may and may not\ncarry.\n\n**The ordering is server-side and total, so the client never sorts.** One\n``ORDER BY`` does all three jobs: dormant last (``public_slug IS NULL``\nsorts false-before-true on both SQLite and Postgres, and compiles to the\nsame SQL on each), then newest-followed first within each group, then row id\nto break a same-instant tie. Live-first is also what makes\n``FOLLOWS_PAGE_LIMIT`` safe — the truncation can only ever drop dormant\nrows. ``ix_agent_follows_user_created`` is what the follow-time part reads.\n\nStill THREE statements regardless of how many agents are followed, and\nregardless of how many are dormant — the page, the batched finding previews,\nand the batched finding totals. The dormant rows ride the page query rather\nthan adding one of their own (the ``public_slug IS NOT NULL`` filter became\npart of the ORDER BY instead of a second SELECT), and the two batched\nstatements are handed only the live ids, so a list that is entirely dormant\ncosts ONE statement: ``_preview_findings`` and ``_shareable_counts`` both\nreturn early on an empty id list. Budget pinned by\n``tests/test_agents_query_budget.py``.\n\n``plan_json`` is deferred with ``raiseload``: it is several KB of cached\nexecution plan per agent, nothing in either payload reads it, and a plain\ndefer would quietly convert a future reference into one SELECT per row.","operationId":"list_follows_api_follows_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Follows Api Follows Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/profile":{"get":{"tags":["profiles"],"summary":"Get My Profile","description":"The signed-in user's profile, or an unsaved default with ``exists: false``.\n\nNever 404s. The client is rendering an editor and an editor needs an object;\nsee the module docstring for why that object is built here rather than in the\nfrontend. Nothing is written.","operationId":"get_my_profile_api_profile_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get My Profile Api Profile Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["profiles"],"summary":"Upsert My Profile","description":"Create or replace the signed-in user's profile.\n\n200 on both create and update, deliberately — not 201 on create. The client\ntreats this as one \"save\" action and branching its success handler on a status\ncode would gain it nothing that ``exists`` in the body does not already say.\n\nIdempotent in the sense that matters: the row is keyed by ``user_id``, so\nsaving twice updates one row and can never produce two profiles for one\nperson. That is a primary key doing the work rather than a code path\nremembering to.","operationId":"upsert_my_profile_api_profile_put","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileUpsert"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Upsert My Profile Api Profile Put"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/profile/job-agent-created":{"post":{"tags":["profiles"],"summary":"Mark Job Agent Created","description":"Record that this profile's job-search agent now exists.\n\nIdempotent. The profile form never sends this field, so saving a headline\ncannot turn it back off. There is no row yet until the profile itself has\nbeen saved, and an agent written from a profile that does not exist has\nnothing to be written from.","operationId":"mark_job_agent_created_api_profile_job_agent_created_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"boolean"},"title":"Response Mark Job Agent Created Api Profile Job Agent Created Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/profile/resume":{"get":{"tags":["profiles"],"summary":"Download My Resume","description":"The signed-in user's private resume PDF. Nobody else may call this.","operationId":"download_my_resume_api_profile_resume_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["profiles"],"summary":"Delete My Resume","description":"Drop the private resume. Owner only.","operationId":"delete_my_resume_api_profile_resume_delete","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/profile/handle-available":{"get":{"tags":["profiles"],"summary":"Check Handle Available","description":"Whether the editor may offer ``handle``. Advisory, never authoritative.\n\nThe answer is a boolean and a coarse reason. It never says who holds a taken\nhandle, because \"taken\" is the entire answer a client needs in order to pick\nanother one, and the alternative is a lookup service that maps handles to\npeople. Requires a session so that walking the namespace costs an account —\nsee the module docstring on enumeration.\n\nYour own current handle comes back ``available: true``. Opening the editor and\npressing Save without touching the field must not be told that you are already\nusing your own name.\n\nA shape violation is a ``false`` with ``reason: \"invalid\"``, not a 422. This is\ncalled as the user types, and \"you have typed two characters so far\" is not a\nclient error.","operationId":"check_handle_available_api_profile_handle_available_get","parameters":[{"name":"handle","in":"query","required":true,"schema":{"type":"string","maxLength":200,"title":"Handle"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Check Handle Available Api Profile Handle Available Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/profile/import-resume":{"post":{"tags":["profiles"],"summary":"Import Resume","description":"Read an uploaded resume PDF, suggest profile fields, and store the file privately.\n\nSuggested fields are NOT written to the profile — only the user's Save does\nthat. The PDF itself IS stored for the owner (Apply / self-use), behind\n``GET /api/profile/resume``. Public profiles and MCP never see it.\n\nThe refusals are ordered cheapest first, and each one is a different status\nbecause each one is a different thing for the user to do:\n\n* 413 — bigger than the cap. Nothing is parsed or stored.\n* 422 — not a PDF, encrypted, damaged, or a scan with no text layer. The\n  message comes from ``resume_pdf`` and names the fix.\n* 503 — this deployment has no inference provider configured, or every\n  configured provider failed to answer. Not the user's fault and not a\n  permanent condition, so not a 4xx. The private PDF is still stored when\n  the file was readable.\n* 429 — monthly inference budget exhausted, theirs or the platform's.\n  Same: the private PDF is kept.\n* 422 — read fine, and the model found nothing usable in it. PDF still kept.","operationId":"import_resume_api_profile_import_resume_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_import_resume_api_profile_import_resume_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Import Resume Api Profile Import Resume Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/profiles":{"get":{"tags":["profiles"],"summary":"List Public Profiles","description":"The directory: everybody who has published a profile, newest first.\n\n## It is also the candidate search\n\nA company account looking for people uses this endpoint with filters on. There\nis deliberately no second \"search\" endpoint: a search IS the directory with a\nWHERE clause, and a parallel endpoint would be a second serialiser, a second\ncache policy and a second place for the contact gate to be got wrong. Every\nfilter below is AND-ed with the others and every one is skipped entirely when\nabsent, so the unfiltered call is byte-for-byte the response it always was.\n\n  ``q``            free text over ``display_name``, ``headline``, ``company``\n                   and ``role`` -- the four fields that answer \"who is this\n                   and what do they do\". OR-ed among themselves, because a\n                   recruiter typing \"stripe\" does not know or care whether it\n                   is somebody's employer or their headline. NOT over ``bio``:\n                   an unindexed ``LIKE`` over a 2,000 character column is the\n                   scan that teaches people to stop using the search.\n  ``skill``        REPEATABLE, and AND-ed. See the block on the EXISTS below.\n  ``open_to_work`` the master switch on the profile, honoured as a switch.\n  ``seniority``    against ``seeking_seniority``, on the shared rung vocabulary.\n  ``remote``       against ``seeking_remote``, on the shared mode vocabulary.\n  ``comp_max``     an affordability ceiling. See the block on NULL below --\n                   it is the one filter here that had a real decision in it.\n  ``location``     case-insensitive contains over ``seeking_locations`` OR\n                   ``location``: where they would go, or where they already\n                   are. One parameter rather than two, because a recruiter\n                   asking for Berlin means \"can this person work in Berlin\"\n                   and does not want to run the query twice to find out.\n  ``based_in``     case-insensitive contains over ``location`` ALONE: where\n                   somebody actually is. See the block below on why this is\n                   not ``location`` with a flag.\n\n**Filtering does not widen the serialiser.** ``_directory_profile`` is an\nallowlist and it is unchanged: it is possible to filter on\n``seeking_comp_min`` and on ``open_to_work`` and to receive neither of them\nback, and that is correct rather than an oversight. A filter is a question\nabout the row; the allowlist is the answer the row has agreed to give. The one\nthat would actually hurt somebody is the contact bundle, which is not in this\npayload in any mode for any viewer -- so no combination of filters can turn\nthis into the bulk export ``_directory_profile`` exists to prevent.\n\n## Why there is no viewer\n\nUnlike every other public read in this codebase, this takes no\n``OptionalUserId``. Nothing in the response depends on who is asking -- no\n``owned``, no ``following``, no contact fields at any visibility -- so\nresolving a viewer would buy nothing and would cost the shared cache, which is\nthe whole reason ``_public_read_cache`` has a private branch. A directory is\nthe one page here that is genuinely the same for everyone, and it is allowed to\nsay so.\n\nThat survives the filters, and it is worth saying why: a filtered response is\nstill the same for every caller who sends the same query string, because a\nquery string is part of a cache key and a viewer is not. So the search is\nshared-cacheable exactly like the unfiltered directory, with no ``Vary`` and no\nprivate branch. Resolving a viewer here -- to sort by \"people like you\", say --\nwould silently cost that, on the surface most likely to be polled.\n\n## Why newest first, and not by followers\n\nRecency is honest with one member and with ten thousand. Ordering by follower\ntotal sounds better and is not available cheaply: followers live on ``agents``,\nso a popularity sort needs the aggregate in the ORDER BY, which means either a\ncorrelated subquery per row or a GROUP BY join that cannot be keyset-paginated\non a value that changes under the reader. The gallery can sort by popularity\nbecause ``agents.follower_count`` is a denormalised column ON the row being\nsorted; there is no equivalent column here, and adding one would mean keeping a\nsecond counter in step with the first.\n\nSo: keyset on ``(created_at DESC, handle DESC)``, which is a total order and\npages correctly even when a hundred profiles share a timestamp -- which they\nwill, the day a backfill creates them.\n\nThat ordering is chosen independently of the filters, which is what makes\npaging a filtered search work at all: every predicate below narrows the set\nwithout touching the sort key, so the cursor still names a position in a total\norder over the *filtered* rows. A relevance sort would break that -- a score\ncomputed from ``q`` is not a stored column, so a cursor could not name a\nposition in it, and the endpoint would have to fall back to offset paging and\nthe skipping-and-repeating it exists to avoid.\n\n## Query budget\n\nThree statements, constant in the page size AND constant in the number of\nfilters: the page of profiles, one batched audience aggregate, one batched tag\nread. Every filter is a predicate inside statement one, including the skill\nones -- see the EXISTS block below for why a join was refused. The two batches\nare the whole reason those helpers exist; per-row versions of either are the\nN+1 this endpoint would otherwise ship on the one page designed to be crawled.","operationId":"list_public_profiles_api_public_profiles_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":60,"minimum":1,"default":12,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Q"}},{"name":"skill","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Skill"}},{"name":"open_to_work","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Open To Work"}},{"name":"seniority","in":"query","required":false,"schema":{"anyOf":[{"enum":["intern","junior","mid","senior","staff","principal","exec"],"type":"string"},{"type":"null"}],"title":"Seniority"}},{"name":"remote","in":"query","required":false,"schema":{"anyOf":[{"enum":["onsite","hybrid","remote"],"type":"string"},{"type":"null"}],"title":"Remote"}},{"name":"comp_max","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":100000000,"minimum":0},{"type":"null"}],"title":"Comp Max"}},{"name":"location","in":"query","required":false,"schema":{"anyOf":[{"type":"string","maxLength":100},{"type":"null"}],"title":"Location"}},{"name":"based_in","in":"query","required":false,"schema":{"anyOf":[{"type":"string","maxLength":100},{"type":"null"}],"title":"Based In"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Public Profiles Api Public Profiles Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/profiles/{handle}":{"get":{"tags":["profiles"],"summary":"Get Public Profile","description":"One person's public profile, plus every agent they have published.\n\nAnonymous-friendly (``OptionalUserId``): a link to somebody's profile is\nsomething they paste into a bio, so it is fetched by strangers, by crawlers\nand by expired sessions, and none of those may see a 401. A resolved viewer\nonly adds the per-viewer bits.\n\n404 for an unknown handle, for a private profile, and for a private profile\nthat exists — one answer for all three, so the response cannot be used to\ndiscover that a name is taken by somebody who chose not to be listed. The\nowner is the exception and gets their own private page, which is how the\npreview-before-publishing flow works.","operationId":"get_public_profile_api_public_profiles__handle__get","parameters":[{"name":"handle","in":"path","required":true,"schema":{"type":"string","title":"Handle"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Public Profile Api Public Profiles  Handle  Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/company":{"get":{"tags":["companies"],"summary":"Get My Company","description":"The signed-in account's company page, or an unsaved default.\n\nNever 404s, exactly as ``GET /api/profile`` never does: the client is\nrendering an editor and an editor needs an object to bind to. Building that\nobject in the frontend instead would define the shape of a company in two\nplaces, and the day a field is added the two disagree.\n\nNot gated on ``account_kind``. The kind decides which onboarding an account is\nshown and which page the product nudges it towards; it is not an entitlement,\nand refusing to hand somebody their own (possibly already saved) company row\nbecause a flag says \"individual\" would make the ordering of two unrelated\ncalls load-bearing and could lock an account out of a page it already owns.\nRoles are gated -- see ``company_account`` -- because that is where the kind\ngenuinely decides what the account is doing.\n\nNothing is written except a missing API key on a company that already\nexists. A read that created a row would mean opening the editor and closing\nit claimed a handle forever. Filling a null key does not claim anything: the\npage was already saved, and Settings has to be able to show the key.","operationId":"get_my_company_api_company_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get My Company Api Company Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["companies"],"summary":"Upsert My Company","description":"Create or replace the signed-in account's company page.\n\n200 on both create and update rather than 201 on create, matching\n``PUT /api/profile``: the client treats this as one \"save\" action, and\nbranching its success handler on a status code gains it nothing that\n``exists`` in the body does not already say.\n\nIdempotent in the sense that matters: the editable row is looked up by\n``user_id`` with ``origin`` null/claimed, so saving twice updates that one\npage and never overwrites an ``imported`` directory listing the same account\nmay also own.\n\n## The handle check happens twice, on purpose\n\nOnce here against BOTH tables (``handle_owner``) and once in the database\nagainst this table's unique index. The first is the only thing that can catch\na collision with a PERSON -- no index spans the two tables, see\n``api.handles`` -- and it produces a clear 409 before anything is written. The\nsecond closes the race the first cannot: between the check and the INSERT,\nanother company can take the name, and only the index is authoritative about\nthat.\n\n## ``published_at`` is set once and never cleared\n\nStamped the first time ``is_public`` goes true, left alone on every later\nsave, and deliberately NOT cleared when a page is unpublished. It answers \"how\nlong have they been here\", which is a trust signal that survives a company\ntaking its page down for a fortnight; clearing it would make a returning\ncompany look brand new, and re-publishing would then quietly mint a fresh\nfounding date for an account that has been here two years.","operationId":"upsert_my_company_api_company_put","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompanyUpsert"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Upsert My Company Api Company Put"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/company/handle-available":{"get":{"tags":["companies"],"summary":"Check Handle Available","description":"Whether the company editor may offer ``handle``. Advisory, never authoritative.\n\nByte-for-byte the same contract as ``GET /api/profile/handle-available``,\nincluding the answers: a boolean and a coarse reason, never the holder. That\nis not duplication for its own sake -- the two editors are two screens asking\none question about one namespace, and an answer that differed between them\nwould be a bug in whichever one a user believed.\n\n**It checks both tables, and that is the whole reason it exists as a second\nendpoint rather than the profile one being reused.** A company editor told\nthat ``stripe`` is free because no other COMPANY holds it would let somebody\npublish a page that collides with a person, which is the collision the shared\nnamespace exists to prevent.\n\nYour own handle comes back available, whichever table holds it: reopening the\neditor and pressing Save without touching the field must not be told your own\nname is taken.\n\nA shape violation is a ``false`` with ``reason: \"invalid\"``, not a 422. This is\ncalled as the user types, and \"you have typed two characters so far\" is not a\nclient error.","operationId":"check_handle_available_api_company_handle_available_get","parameters":[{"name":"handle","in":"query","required":true,"schema":{"type":"string","maxLength":200,"title":"Handle"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Check Handle Available Api Company Handle Available Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/companies":{"get":{"tags":["companies"],"summary":"List Public Companies","description":"Every company that has published a page, newest first.\n\n## It is also the customer search\n\nAn account looking for who to sell to uses this endpoint with filters on --\nthe ICP query, in the language of the people who will run it. There is\ndeliberately no second endpoint for that: a customer search IS this directory\nwith a WHERE clause, and the symmetry with ``profiles.list_public_profiles``\nis the point. Both sides of the network are searched the same way, by the same\nkind of caller, with the same cache policy and the same allowlist.\n\nEvery filter is AND-ed with the others and skipped entirely when absent, so an\nunfiltered call is byte-for-byte the response it always was.\n\n  ``q``         free text over ``name``, ``tagline`` and ``industry``, OR-ed\n                among themselves. Those three are what a company says it is.\n                NOT over ``about``: that column is up to 6,000 characters of\n                prose, and an unindexed ``LIKE`` across a page of them is the\n                scan that makes a directory feel broken.\n  ``industry``  exact and case-insensitive, for the caller who already knows\n                the label and wants the whole bucket rather than a substring\n                of it. Both are offered because they are different questions:\n                ``q=fin`` is exploration, ``industry=Fintech`` is a segment.\n  ``size``      exact, on the closed ``COMPANY_SIZES`` vocabulary. A typo is a\n                422 rather than an empty page, which is what makes \"no\n                companies that size\" believable when it comes back.\n  ``location``  case-insensitive contains, because company locations are typed\n                (\"Bengaluru, India\") and an equality test would match almost\n                nothing.\n  ``hiring``    the highest-signal filter here. See below.\n\n**Filtering does not widen the serialiser.** ``_directory_company`` is\nunchanged, so ``contact_email`` is still absent from every row no matter which\nfilter matched -- which matters more here than it looks, because this is\nprecisely the endpoint somebody would point a lead-generation script at. A\nfilter is a question about the row; the allowlist is the answer the row agreed\nto give, and the two are not connected.\n\n## ``hiring``, and why it is an EXISTS\n\nA company with an open backend role is a company that is spending money on\nbuilding software, which is the single most useful thing this directory knows\nabout who is worth talking to. So it is a first-class filter rather than\nsomething a caller derives by reading ``open_role_count`` off a page they\nalready paid for.\n\nIt is a correlated EXISTS and deliberately not a join. A join onto ``roles``\nreturns one row per open role, so a company with three openings appears three\ntimes: three duplicates in the page, three duplicates fed into\n``_open_role_counts``, and -- worst -- a page of ``limit + 1`` ROWS that is not\n``limit + 1`` COMPANIES, which silently breaks the keyset over-fetch that\ndecides ``has_more``. A DISTINCT would paper over the duplicates and then\nfight the ORDER BY. EXISTS also stops at the first matching role rather than\ncounting them all, which is the whole question being asked.\n\n``hiring=false`` is not a filter for \"companies that are not hiring\". Like\n``open_to_work`` on the people directory, this parameter is a switch: on\nnarrows, off and absent are the same. An unticked box in a filter bar must not\nsend a request that hides everybody the box was about.\n\n## No viewer\n\nLike the people directory and unlike every other public read here, this takes\nno ``OptionalUserId``. Nothing in the response depends on who is asking, so\nresolving a viewer would buy nothing and would cost the shared cache -- which\nis the entire reason this response is cacheable at all. That survives the\nfilters: a filtered response is still identical for every caller who sends the\nsame query string, and a query string is part of a cache key where a viewer is\nnot.\n\n## Newest first, not by size or by open roles\n\nRecency is honest with one company listed and with ten thousand. Sorting by\nopen-role count sounds more useful and cannot be keyset-paginated: the count\nis an aggregate over another table that changes under the reader, so a cursor\nbuilt on it would skip and repeat rows as companies publish and close\nopenings. Sorting by ``size`` would be sorting a page of strangers by a\nself-reported bucket, which is an advert rather than an ordering.\n\nSo: keyset on ``(created_at DESC, handle DESC)``, a total order that pages\ncorrectly even when a hundred companies share a timestamp -- which they will,\nthe day a backfill creates them.\n\nNone of the filters touch the sort key, which is what makes paging a filtered\nsearch work: every predicate narrows the set, the cursor still names a\nposition in a total order over what is left, and a deeper page costs no more\nstatements than the first.\n\n## Query budget\n\nTwo statements, constant in the page size and constant in the number of\nfilters: the page, and one batched open-role tally. Every filter lives inside\nstatement one, including ``hiring`` -- a correlated EXISTS is part of the same\nstatement, where a \"does this company have roles?\" lookup per row would be the\nN+1 this endpoint exists to avoid on a page designed to be crawled.","operationId":"list_public_companies_api_public_companies_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":60,"minimum":1,"default":12,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Q"}},{"name":"industry","in":"query","required":false,"schema":{"anyOf":[{"type":"string","maxLength":80},{"type":"null"}],"title":"Industry"}},{"name":"size","in":"query","required":false,"schema":{"anyOf":[{"enum":["1-10","11-50","51-200","201-500","501-1000","1001-5000","5000+"],"type":"string"},{"type":"null"}],"title":"Size"}},{"name":"location","in":"query","required":false,"schema":{"anyOf":[{"type":"string","maxLength":100},{"type":"null"}],"title":"Location"}},{"name":"hiring","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Hiring"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Public Companies Api Public Companies Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/companies/{handle}":{"get":{"tags":["companies"],"summary":"Get Public Company","description":"One company page, plus every role it currently has open.\n\nAnonymous-friendly (``OptionalUserId``): a link to a company page is something\nthat gets pasted into a job ad, so it is fetched by strangers, by crawlers and\nby expired sessions, and none of those may see a 401. A resolved viewer only\nadds ``owned``.\n\n404 for an unknown handle, for a private company, and for a handle held by a\nperson -- one answer for all three, so the response cannot be used to walk the\nshared namespace. The owner is the exception and gets their own private page,\nwhich is how preview-before-publishing works.\n\n## The view counter\n\nIncremented with a targeted UPDATE rather than read-modify-write on the loaded\nrow, so two concurrent readers cannot each read 7 and each write 8. The commit\nexpires the loaded row, which is why the roles are read AFTER it: reading them\nfirst would leave a page of expired ORM objects to be refreshed one SELECT at\na time by the serialiser, which is an N+1 hidden inside a commit. Same pattern\nand the same ordering discipline as ``public.get_public_agent``.\n\n**The owner's own visits do not count.** A company reloading its page while\nediting it would otherwise inflate the one number it is going to use to decide\nwhether any of this is working, and a metric that mostly measures its own\nsubject is worse than no metric.","operationId":"get_public_company_api_public_companies__handle__get","parameters":[{"name":"handle","in":"path","required":true,"schema":{"type":"string","title":"Handle"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Public Company Api Public Companies  Handle  Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/profiles/{handle}/call-requests":{"post":{"tags":["profile-actions"],"summary":"Request Call","description":"Record that this visitor wants to book a paid call with this profile.\n\nReturns ``{\"status\": \"requested\", \"price_cents\": n, \"requested_at\": ...}``.\nIdempotent per (visitor, profile, UTC day): a second press returns the first\nrow rather than writing another, so a double-tap is not two requests in the\noperator's queue.\n\n404 for an unknown or unpublished handle and for a profile that does not\noffer calls -- one answer, so \"does this person sell calls\" cannot be probed\non a profile the caller cannot otherwise see. 400 for one's own profile.","operationId":"request_call_api_public_profiles__handle__call_requests_post","parameters":[{"name":"handle","in":"path","required":true,"schema":{"type":"string","title":"Handle"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Request Call Api Public Profiles  Handle  Call Requests Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/profiles/{handle}/messages":{"post":{"tags":["profile-actions"],"summary":"Send Hiring Message","description":"A company account emails a person about a role, through Parlel.\n\nThe order of refusals is the order a sender can fix them in:\n\n  403 / 409  not a company account / no company page yet (``company_account``)\n  409        the company page has no ``contact_email`` -- there would be\n             nobody to CC and nowhere for a reply to go\n  404        unknown or unpublished profile\n  400        messaging yourself\n  403        this person has turned hiring messages off\n  409        this person has no address we can reach\n  429        the company's ten for today are spent\n  502        the mail did not go out; the attempt still counts\n\nReturns ``{\"status\": \"sent\", \"remaining_today\": n}``.","operationId":"send_hiring_message_api_public_profiles__handle__messages_post","parameters":[{"name":"handle","in":"path","required":true,"schema":{"type":"string","title":"Handle"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HiringMessageIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Send Hiring Message Api Public Profiles  Handle  Messages Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/profile/work-email":{"get":{"tags":["profile-actions"],"summary":"Get Work Verification","operationId":"get_work_verification_api_profile_work_email_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Work Verification Api Profile Work Email Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["profile-actions"],"summary":"Remove Work Verification","description":"Withdraw the verification. Idempotent; the badge disappears at once.","operationId":"remove_work_verification_api_profile_work_email_delete","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Remove Work Verification Api Profile Work Email Delete"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/profile/work-email/send":{"post":{"tags":["profile-actions"],"summary":"Send Work Code","description":"Email a six-digit code to a work address.\n\n422 for a malformed address or a consumer mailbox; 409 if the profile has no\ncompany yet (there is nothing to verify AGAINST); 429 inside the resend\ncooldown; 502 if the mail did not go out. The code is stored hashed and a new\nsend replaces any pending one.","operationId":"send_work_code_api_profile_work_email_send_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkEmailIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Send Work Code Api Profile Work Email Send Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/profile/work-email/verify":{"post":{"tags":["profile-actions"],"summary":"Verify Work Code","description":"Check the code. On success, the badge is live for the current company name.\n\n409 when no code is pending; 410 when it has expired or been guessed at too\noften (send a new one); 422 for a wrong code, with the attempts remaining.","operationId":"verify_work_code_api_profile_work_email_verify_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkCodeIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Verify Work Code Api Profile Work Email Verify Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/posts":{"get":{"tags":["posts"],"summary":"List Own Posts","description":"Everything this account has posted, newest first.\n\nIncludes posts on an UNPUBLISHED profile, which is the point of it being a\nseparate endpoint from the public one: the owner has to be able to write and\nreview before they publish, and the public read would show them nothing.","operationId":"list_own_posts_api_posts_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":50,"minimum":1,"default":20,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Own Posts Api Posts Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["posts"],"summary":"Create Post","description":"Write a post.\n\nA profile is required, and the 422 says so rather than 404ing. A post is\nprofile content -- it is displayed at ``/u/{handle}`` and it is bound to the\nprofile row by a foreign key -- so an account with no profile has nowhere to\nput one. This is the same shape as showing interest in a role, which also\nrequires a profile and also explains itself instead of failing quietly.\n\nPublishing is NOT required. Somebody can write before they publish, and\neverything they wrote goes public with the profile when they do; refusing\nwould mean a new account has to publish an empty page before it can put\nanything on it.","operationId":"create_post_api_posts_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Create Post Api Posts Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/posts/{post_id}":{"patch":{"tags":["posts"],"summary":"Update Post","description":"Fix a typo.\n\n``updated_at`` moves and ``created_at` does not, which is what makes the card\nsay \"edited\" while keeping its place in the timeline. Bumping the sort date on\nan edit would drag a two-month-old post to the top of a stream somebody is\nreading, which is the behaviour that makes edited posts feel like a trick.","operationId":"update_post_api_posts__post_id__patch","parameters":[{"name":"post_id","in":"path","required":true,"schema":{"type":"string","title":"Post Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Update Post Api Posts  Post Id  Patch"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["posts"],"summary":"Delete Post","operationId":"delete_post_api_posts__post_id__delete","parameters":[{"name":"post_id","in":"path","required":true,"schema":{"type":"string","title":"Post Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/profiles/{handle}/posts":{"get":{"tags":["posts"],"summary":"List Profile Posts","description":"Everything one published profile has posted.\n\nThis is where the profile page's \"see all\" goes, and it is deliberately\nanonymous and shared-cacheable: no viewer is resolved, so no per-viewer field\ncan appear and a CDN may hold one copy for everybody. That also makes it the\nendpoint an external agent should read, which is why it is guessable from the\nprofile URL rather than living under a different prefix.\n\nAn unpublished or unknown handle is a 404 either way, which is what keeps an\nunlisted profile indistinguishable from one that never existed.","operationId":"list_profile_posts_api_public_profiles__handle__posts_get","parameters":[{"name":"handle","in":"path","required":true,"schema":{"type":"string","title":"Handle"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":50,"minimum":1,"default":20,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Profile Posts Api Public Profiles  Handle  Posts Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/posts":{"get":{"tags":["posts"],"summary":"List Public Posts","description":"What is happening on Parlel: people's posts, agents' findings, or both.\n\n## The response carries no per-viewer field, on purpose\n\n``user_id`` is accepted and then used for nothing but the cache header. That\nlooks like a mistake and is not: this is the one page a stranger is most\nlikely to land on from a search engine, it is anonymous and shared-cacheable\nin that state, and adding a Follow flag or an ``owned`` bit would make every\nresponse ``private, no-store`` for the sake of a button that is not on the\ncard. If those are wanted later they belong on a second, small request.\n\n## Ordering\n\nStrictly ``(created_at DESC, id DESC)`` across both kinds, so the two halves\ninterleave by time rather than being grouped. Grouping would defeat the\nfilter's purpose -- if the human posts all sat above the findings, nobody\nwould need to switch.","operationId":"list_public_posts_api_public_posts_get","parameters":[{"name":"author","in":"query","required":false,"schema":{"enum":["all","human","agent"],"type":"string","default":"all","title":"Author"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":50,"minimum":1,"default":20,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Public Posts Api Public Posts Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/index":{"get":{"tags":["discovery"],"summary":"Discovery Index","description":"Everything a machine can read here, with absolute URLs.\n\nAbsolute rather than relative on purpose: an agent that found this document\nthrough ``llms.txt`` on the web origin would otherwise have to know that the\nAPI lives on a different host, which is exactly the kind of thing that makes\nan integration guess.\n\nNo authentication, and no per-viewer field, so this is shared-cacheable like\nthe endpoints it describes.","operationId":"discovery_index_api_public_index_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Discovery Index Api Public Index Get"}}}}}}},"/api/roles/{role_id}/interest":{"get":{"tags":["roles"],"summary":"Get My Interest","description":"Whether the caller has shown interest in this role.\n\nIts own endpoint rather than a field on ``GET /api/public/roles/{id}``, and\nthe reason is the cache. That response is anonymous and shared-cacheable --\nidentical bytes for every reader, which is what makes a role page cheap. One\nper-viewer boolean in it would force ``private, no-store`` on the whole\npayload and turn every visit into an origin hit. So the expensive, shared,\ncrawlable half stays shared, and the one bit that differs per person is a\nsecond small request that only a signed-in reader ever makes.\n\nAnswers for a CLOSED role too, unlike the write paths: a candidate looking at\na role that closed should still be told they had put their hand up.","operationId":"get_my_interest_api_roles__role_id__interest_get","parameters":[{"name":"role_id","in":"path","required":true,"schema":{"type":"string","title":"Role Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get My Interest Api Roles  Role Id  Interest Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["roles"],"summary":"Show Interest","description":"Put your hand up for a role. Idempotent.\n\n## Why a profile is required\n\n422 for an account with no ``user_profiles`` row. What a company receives is a\nlist of people, and a person with no profile is a line that says nothing: no\nname, no headline, no skills, nothing to decide on. Sending that is worse than\nsending nothing, because it costs the company a click to find out it was\nempty and it tells the candidate their interest was registered when in\npractice it cannot be acted on. The message names the fix and the client links\nto it.\n\nNote what is NOT required: a PUBLIC profile. A private profile 404s for\nstrangers, but expressing interest is a deliberate, per-role disclosure to one\ncompany, and requiring publication would make \"let this one employer see me\"\nimpossible without also making \"let everybody see me\" true. The interest is\nthe consent, and it is scoped to the role it was given on.\n\n## Why you cannot show interest in your own role\n\n409, because it is a category error rather than a permission problem, and\nbecause a company appearing in its own candidate list would be the first bug\nreported.","operationId":"show_interest_api_roles__role_id__interest_post","parameters":[{"name":"role_id","in":"path","required":true,"schema":{"type":"string","title":"Role Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Show Interest Api Roles  Role Id  Interest Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["roles"],"summary":"Withdraw Interest","description":"Take your hand back down. Idempotent, and it deletes rather than tombstones.\n\nInterest is a live signal: what a company sees has to be the people who want\nthe job now, not everybody who ever did. See ``models.RoleInterest``.\n\nNo role lookup and no status check. Withdrawing from a role that has since\nclosed, or whose company has since gone private, must always work -- refusing\nwould strand somebody in a list they cannot leave, which is the one direction\nthis endpoint must never fail in.","operationId":"withdraw_interest_api_roles__role_id__interest_delete","parameters":[{"name":"role_id","in":"path","required":true,"schema":{"type":"string","title":"Role Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roles/{role_id}/interested":{"get":{"tags":["roles"],"summary":"List Interested","description":"The people who put their hand up for one of YOUR roles.\n\nOwner-only, through ``_owned_role``, so a stranger gets the same 404 they get\nfor a draft. This is the payoff of the whole feature and it is also the most\nsensitive read in the company surface: it is a list of named people who are\nlooking, attached to a specific job.\n\nEach person is rendered by ``_directory_profile`` -- the SAME serialiser the\ncandidate search uses. That is deliberate and load-bearing: it is a hard\nallowlist that cannot carry a contact field in any mode, so showing interest\nhands a company a profile to read and never an email address to mail. If a\ncandidate wants to be contactable they open their contact gate, which is one\ndecision they make once, on their own page, rather than a side effect of\npressing a button on a job.\n\nNewest first, because the question a company opens this with is \"who is new\nsince I last looked\".","operationId":"list_interested_api_roles__role_id__interested_get","parameters":[{"name":"role_id","in":"path","required":true,"schema":{"type":"string","title":"Role Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Interested Api Roles  Role Id  Interested Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roles":{"get":{"tags":["roles"],"summary":"List My Roles","description":"Every role this company owns, any status, newest first.\n\nDrafts, open roles and closed ones together, because this is the company's\nworking list and hiding two thirds of it behind a filter would make \"where did\nmy draft go\" the first support question. The board's rules do not apply here:\nthey are about what strangers may see.\n\nOrdered by ``created_at`` rather than ``published_at``, because a draft has no\n``published_at`` and would sort into a null clump at one end -- and the row a\nposter is most likely looking for is the one they just made.\n\nCompany accounts only. See ``companies.company_account`` for why that is a 403\nand a 409 rather than one status.","operationId":"list_my_roles_api_roles_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","additionalProperties":true},"title":"Response List My Roles Api Roles Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["roles"],"summary":"Create Role","description":"Create a role. Always a draft, never live on the board.\n\n201 here and 200 on the company upsert, and the difference is real rather than\nstylistic: this creates a NEW addressable thing with an id the client did not\nhave, which is exactly what 201 is for. A company page is keyed by the account\nand is a save.\n\nNothing published by this call. See ``RoleCreate`` for why ``status`` is not\nan accepted field.","operationId":"create_role_api_roles_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Create Role Api Roles Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roles/{role_id}":{"get":{"tags":["roles"],"summary":"Get My Role","description":"One of the caller's own roles, in full. 404 for anybody else's.","operationId":"get_my_role_api_roles__role_id__get","parameters":[{"name":"role_id","in":"path","required":true,"schema":{"type":"string","title":"Role Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get My Role Api Roles  Role Id  Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["roles"],"summary":"Update My Role","description":"Change one of the caller's own roles. Absent fields are left alone.\n\n## Editing an open role is allowed, and is not a loophole\n\nA live role can be edited: a salary band moves, a location changes, the title\nwas wrong. The alternative -- freeze on publish, force a close-and-repost --\nwould break every link to the role in order to fix a typo in it, which is the\noutcome the whole closed-stays-readable rule exists to avoid.\n\nWhat it cannot do is change ``status``. Publishing and closing are their own\nendpoints because each has a precondition and a timestamp that is written\nonce, and a settable status field would be a second path to both that happened\nto skip them.","operationId":"update_my_role_api_roles__role_id__patch","parameters":[{"name":"role_id","in":"path","required":true,"schema":{"type":"string","title":"Role Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RolePatch"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Update My Role Api Roles  Role Id  Patch"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["roles"],"summary":"Delete Role","description":"Delete a role that was never published. Refuse to delete one that was.\n\n## Why this is not an unconditional delete\n\nA role that has been open has been linked to -- in a message, in a newsletter,\nin somebody's saved tabs, and in an agent's finding, which is a claim this\nproduct made to a user about the world at a moment in time. Deleting the row\nbreaks all of those and rewrites the record so the opening never existed. That\nis the exact failure ``models.Role``'s three-state ``status`` was designed\naround, and an endpoint that undoes it in one call would make the design\ndecorative.\n\nSo: a draft is genuinely disposable and is deleted outright. Anything with a\n``published_at`` is refused with a 409 that names the alternative, which is\n``POST /api/roles/{id}/close`` -- it removes the role from the board and from\nevery match, and leaves the URL resolving.\n\n``published_at`` rather than ``status`` is the test, deliberately. A role that\nwas open and is now closed has a null-free ``published_at`` and a status of\n``closed``; testing the status would let a company close a role and then delete\nit, which is the same broken links by two steps.\n\n204 with no body: there is nothing useful to say, and a body on a delete is a\nthing clients then start parsing.","operationId":"delete_role_api_roles__role_id__delete","parameters":[{"name":"role_id","in":"path","required":true,"schema":{"type":"string","title":"Role Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roles/{role_id}/publish":{"post":{"tags":["roles"],"summary":"Publish Role","description":"Put a role on the board. Requires the company page to be public already.\n\n## Why the company has to be public first\n\nA role links back to its company for everything a candidate needs in order to\ndecide whether to apply -- who they are, what they do, where they are, how to\nreach them. If the company page is private, ``GET /api/public/companies/{handle}``\n404s, the board's join excludes the role anyway, and the \"role_url\" in every\npayload points at a page nobody can load. Publishing into that state would\nreport success and produce nothing: an opening that is live, unreachable, and\ninvisible.\n\nSo it is a 422 with a sentence naming the fix, rather than a silent success or\na cascade that publishes the company page on the user's behalf. Publishing a\ncompany page is a consent decision (see ``models.Company.is_public``) and it is\nnot this endpoint's to make.\n\n## Idempotent, and the timestamp is written once\n\nPublishing an already-open role is a 200 and changes nothing. ``published_at``\nis stamped only if it is null, so a role that was open, closed, and reopened\nkeeps its ORIGINAL publication date -- which is the fact that matters, both for\nthe board's ordering and for anyone asking how long this opening has been\naround. ``closed_at`` is cleared on the way back to open, because a role that\nis open now is not closed and leaving a stale timestamp would make the payload\nsay both.","operationId":"publish_role_api_roles__role_id__publish_post","parameters":[{"name":"role_id","in":"path","required":true,"schema":{"type":"string","title":"Role Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Publish Role Api Roles  Role Id  Publish Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roles/{role_id}/close":{"post":{"tags":["roles"],"summary":"Close Role","description":"Take a role off the board without breaking its link.\n\nThis is the endpoint the whole three-state design exists for. After it: the\nrole is gone from ``GET /api/public/roles``, gone from the company page's list,\nand gone from every match -- and ``GET /api/public/roles/{id}`` still returns\nit, with ``status: \"closed\"`` and a ``closed_at``, so every link that was ever\nshared keeps resolving and every agent that ever cited it can still show what\nit cited.\n\nClosing a draft is allowed and is a no-op worth having: it is how a company\nretires something it started writing and does not want in its working list as\nan unfinished thing. ``published_at`` stays null in that case, so the role\nnever appears anywhere public, which is correct -- it never was public.\n\nIdempotent. Closing a closed role keeps the ORIGINAL ``closed_at``: when the\nopening stopped being available is a fact, and overwriting it on a stray\ndouble click would replace it with a lie.","operationId":"close_role_api_roles__role_id__close_post","parameters":[{"name":"role_id","in":"path","required":true,"schema":{"type":"string","title":"Role Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Close Role Api Roles  Role Id  Close Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/roles":{"get":{"tags":["roles"],"summary":"List Public Roles","description":"THE PUBLIC JOB BOARD. The most important open-data surface in this product.\n\nEverything else in this file exists to fill this endpoint, and everything\nabout it is chosen on the assumption that **its primary consumer is not a\nbrowser**. It is what external agents read: an agent watching for senior Rust\nroles above a salary floor is the canonical use of this product, and its\nexperience of Parlel is this JSON. Treat a change here the way you would treat\na change to a published API, because that is what it is.\n\nThree consequences follow, and they are the reason this docstring is long.\n\n**It is anonymous, and it stays anonymous.** No ``OptionalUserId``, no\nper-viewer field, nothing that varies by who is asking -- which is what lets\nthe whole response be stored by a shared cache and served from it. An agent\npolling every five minutes should be absorbed by a CDN, not by this process.\nThe day somebody adds a \"saved\" flag to these cards, every one of those polls\nstarts hitting Postgres.\n\n**It is keyset paged, not offset paged.** ``(published_at DESC, id DESC)``,\nover-fetched by one. An agent walking the whole board while companies publish\ninto it is the exact workload offset paging gets wrong -- rows shift under the\nreader and the page after an ``OFFSET 24`` silently skips whatever landed\nabove it. A cursor cannot skip.\n\n**Its filters are narrow on purpose.** Title text, remote mode, seniority,\nlocation substring, and a compensation floor. Location uses the same\n``LIKE`` pattern as the company directory (acceptable at current board\nsize; revisit with an index if the table grows large). Full-text search\nover descriptions and skill filters still wait on dedicated indexes.\n\n## What is on the board, and what is not\n\n``status == \"open\"`` AND the company is public. Both halves, joined in one\nstatement rather than filtered in Python, because \"the row never leaves the\ndatabase\" is a stronger guarantee than \"the serialiser remembered\".\n\nA draft is a role its owner has not decided to show anybody. A role belonging\nto a private company is unreachable even when open -- the company page it links\nto 404s -- so listing it would be advertising a dead end. A closed role stays\nreadable at its own URL and never appears here; that split is the whole point\nof having three statuses.\n\n## The compensation filter, and its honest sharp edge\n\n``comp_min=130000`` returns roles whose ``comp_max`` clears the floor OR whose\n``comp_min`` does. The first is the main clause: a role paying 120k-150k\nclears a 130k floor and a filter that compared only against the bottom of the\nband would hide it. The second catches a role that published only a floor.\n\nThe sharp edge: a role that published NO compensation at all is excluded, and\nthat is a real exclusion -- possibly a large one, since many companies post no\nsalary. It is still the right answer. Including them would mean answering\n\"show me roles above 130k\" with a list that mostly cannot be checked, and a\nfilter that silently returns things it did not verify is worse than one that\nreturns fewer things. Currency is deliberately NOT converted: comparing a\nnumber against a floor in another currency would be a made-up exchange rate,\nso the caller filters on the amount and reads the currency off each card.\n\n## Query budget\n\nTwo statements, constant in the page size: the page (roles joined to their\ncompanies) and one batched skill read. There is no third for the company\ndetails because the join already carries them -- a per-card company lookup is\nthe N+1 this endpoint would otherwise ship on the one page designed to be\ncrawled.","operationId":"list_public_roles_api_public_roles_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":60,"minimum":1,"default":12,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Q"}},{"name":"remote","in":"query","required":false,"schema":{"anyOf":[{"enum":["onsite","hybrid","remote"],"type":"string"},{"type":"null"}],"title":"Remote"}},{"name":"seniority","in":"query","required":false,"schema":{"anyOf":[{"enum":["intern","junior","mid","senior","staff","principal","exec"],"type":"string"},{"type":"null"}],"title":"Seniority"}},{"name":"location","in":"query","required":false,"schema":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Location"}},{"name":"comp_min","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":100000000,"minimum":0},{"type":"null"}],"title":"Comp Min"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Public Roles Api Public Roles Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/public/roles/{role_id}":{"get":{"tags":["roles"],"summary":"Get Public Role","description":"One role, readable while it is open AND after it is closed.\n\n## The rule this endpoint exists to keep\n\nA closed role still resolves here. Links to it exist in messages, in\nnewsletters, in saved tabs, and in agent findings that cited it as evidence --\nand an agent's citation that 404s is worse than useless, because it makes the\nfinding look fabricated. So closing removes a role from the board and from\nmatching, and changes nothing at all about whether this URL works. The\nresponse says ``status: \"closed\"`` and carries ``closed_at``, which is what\nlets a reader see the truth: this was real, and it is over.\n\n404 in exactly two cases, and both are about something that was never public:\na draft, and a role whose company page is private. One answer for both, plus\nfor an id that does not exist, so this cannot be used to discover that a\ncompany is quietly staging openings.\n\nAnonymous, like the board. A shared link is followed by strangers, by crawlers\nand by expired sessions, and none of those may see a 401. It takes no viewer at\nall, not even an optional one: there is no owner-preview case to serve, because\na draft 404s here for its own author too (the owner's view of a draft is\n``GET /api/roles/{id}``), so resolving a viewer could only ever change the view\ncounter and never the body -- which is what keeps this response identical for\neveryone and therefore shared-cacheable.\n\nThe consequence, and it is a real one: unlike the company page, **a company's\nown visits to its role page are counted.** The mitigation is that managing\nroles does not go through here -- ``GET /api/roles`` and the editor never touch\nthe counter -- so inflating it takes deliberately opening the public page.\n\nThe view counter uses the same targeted UPDATE as\n``companies.get_public_company`` -- atomic, so two concurrent readers cannot\neach read 7 and each write 8 -- and the commit expires the loaded rows, which\nis why the skills are read after it rather than before.","operationId":"get_public_role_api_public_roles__role_id__get","parameters":[{"name":"role_id","in":"path","required":true,"schema":{"type":"string","title":"Role Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Public Role Api Public Roles  Role Id  Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/billing":{"get":{"tags":["billing"],"summary":"Get Billing","operationId":"get_billing_api_billing_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Get Billing Api Billing Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/billing/status":{"get":{"tags":["billing"],"summary":"Billing Status","description":"Whether Pro can be bought here. Unauthenticated, and deliberately tiny.\n\nThe marketing pages -- ``/pricing`` and the landing page's pricing band -- carry\nsentences like \"Pro is not open yet. Billing is still being built.\" Those are\nhonest today and become **false** the moment credentials are added, on the two\npages most likely to be read by somebody deciding whether to trust this product.\nA signed-out visitor cannot call ``GET /api/billing``, so without this they would\nkeep reading it.\n\nExposes exactly two booleans and nothing about the account, the keys, or the\nproduct. That is why it needs no auth: there is nothing here an anonymous visitor\ncould not infer by pressing the button.\n\nCached at the edge. The answer changes when a deployment's configuration changes,\nwhich is on the order of once ever, and this sits on the landing page.","operationId":"billing_status_api_billing_status_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Billing Status Api Billing Status Get"}}}}}}},"/api/billing/checkout":{"post":{"tags":["billing"],"summary":"Start Checkout","description":"Create a hosted checkout and return its URL for the client to navigate to.\n\nReturns the URL rather than issuing a 302. A redirect from an XHR is followed by\nthe browser and lands the JSON response in a fetch the client cannot use, so the\nclient has to navigate. Being explicit about that here beats a redirect that\nhappens to work only because someone used a form post.\n\nAlready-Pro is refused with 409 rather than quietly making a second subscription.\nTwo live subscriptions for one account is a double charge, and the honest answer\nto \"buy Pro\" when you already have it is \"you already have it\".","operationId":"start_checkout_api_billing_checkout_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Start Checkout Api Billing Checkout Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/billing/webhook":{"post":{"tags":["billing"],"summary":"Dodo Webhook","description":"Apply one Dodo event. Unauthenticated, signature-verified. See the module docs.\n\n## The status codes, and why each one\n\n``503`` billing is not configured. Retryable, and correctly so: if a deploy lost\nits secret, Dodo's retry schedule is the thing that recovers the event once it is\nput back.\n\n``401`` the signature did not verify. Never retried usefully, but a 401 is what\nthe dashboard should show for a misconfigured secret rather than a 400 that reads\nas \"our payload was malformed\".\n\n``400`` the body is not JSON. A retry cannot fix it, and it should be loud.\n\n``200`` everything else, INCLUDING events we did not act on and duplicates. Dodo\nretries any non-2xx up to 8 times over ~23 hours, so answering non-2xx to an event\nthat will never be handled buys eight deliveries of a thing we are going to ignore\neight times. The response body says what happened; the status code says \"stop\nsending this\".\n\n## Not processed asynchronously, deliberately\n\nThe vendor's guidance is to acknowledge immediately and process in the background,\nwhich is right when processing is slow. Here it is one INSERT, one UPDATE and a\nrecompute, inside the 15-second timeout with room to spare — and doing it inline\nmeans the transaction that records the event is the transaction that applies it.\nBackgrounding it would trade that guarantee for latency this endpoint does not\nneed.","operationId":"dodo_webhook_api_billing_webhook_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Dodo Webhook Api Billing Webhook Post"}}}}}}},"/api/admin/usage":{"get":{"tags":["admin"],"summary":"Admin Usage","description":"Hourly machine-traffic rows for the window, newest first.\n\nSurfaces: ``mcp`` (per tool), ``public`` (per templated path), ``pages``\n(per page kind), ``feeds`` (per feed). ``ua_family`` is a normalized label,\nnever a raw User-Agent.","operationId":"admin_usage_api_admin_usage_get","parameters":[{"name":"window","in":"query","required":false,"schema":{"enum":["1h","24h","7d"],"type":"string","default":"24h","title":"Window"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Admin Usage Api Admin Usage Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AccountKindUpdate":{"properties":{"kind":{"type":"string","enum":["individual","company"],"title":"Kind"}},"type":"object","required":["kind"],"title":"AccountKindUpdate","description":"Which front door this account came through.\n\nA ``Literal`` rather than a validated string, so an unknown value is a 422\nnaming both legal ones rather than a row in a state nothing in the product\nknows how to render."},"AgentCreate":{"properties":{"name":{"type":"string","maxLength":120,"minLength":1,"title":"Name"},"task":{"type":"string","maxLength":4000,"minLength":15,"title":"Task"},"interval_minutes":{"type":"integer","title":"Interval Minutes"},"digest_mode":{"type":"string","enum":["instant","hourly","6hourly","daily"],"title":"Digest Mode","default":"daily"},"digest_hour":{"type":"integer","maximum":23.0,"minimum":0.0,"title":"Digest Hour","default":8},"relevance_threshold":{"type":"integer","maximum":100.0,"minimum":0.0,"title":"Relevance Threshold","default":60},"model_tier":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model Tier"},"watch_url":{"anyOf":[{"type":"string","maxLength":2000},{"type":"null"}],"title":"Watch Url"}},"type":"object","required":["name","task","interval_minutes"],"title":"AgentCreate"},"AgentUpdate":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":120,"minLength":1},{"type":"null"}],"title":"Name"},"task":{"anyOf":[{"type":"string","maxLength":4000,"minLength":15},{"type":"null"}],"title":"Task"},"interval_minutes":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval Minutes"},"digest_mode":{"anyOf":[{"type":"string","enum":["instant","hourly","6hourly","daily"]},{"type":"null"}],"title":"Digest Mode"},"digest_hour":{"anyOf":[{"type":"integer","maximum":23.0,"minimum":0.0},{"type":"null"}],"title":"Digest Hour"},"relevance_threshold":{"anyOf":[{"type":"integer","maximum":100.0,"minimum":0.0},{"type":"null"}],"title":"Relevance Threshold"},"enabled":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Enabled"},"model_tier":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model Tier"},"watch_url":{"anyOf":[{"type":"string","maxLength":2000},{"type":"null"}],"title":"Watch Url"}},"type":"object","title":"AgentUpdate"},"Body_import_resume_api_profile_import_resume_post":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File","description":"A resume as a PDF, up to 5 MB."}},"type":"object","required":["file"],"title":"Body_import_resume_api_profile_import_resume_post"},"Body_start_session_api_apply_sessions_post":{"properties":{"finding_id":{"type":"string","title":"Finding Id"},"contact":{"type":"string","title":"Contact","description":"JSON object of contact overrides","default":"{}"},"viewport_width":{"type":"integer","title":"Viewport Width","default":0},"viewport_height":{"type":"integer","title":"Viewport Height","default":0},"pixel_ratio":{"type":"number","title":"Pixel Ratio","default":1.0},"resume":{"anyOf":[{"type":"string","contentMediaType":"application/octet-stream"},{"type":"null"}],"title":"Resume"},"use_stored_resume":{"type":"boolean","title":"Use Stored Resume","default":false}},"type":"object","required":["finding_id"],"title":"Body_start_session_api_apply_sessions_post"},"CompanyUpsert":{"properties":{"handle":{"type":"string","maxLength":30,"minLength":3,"title":"Handle"},"name":{"type":"string","maxLength":120,"minLength":1,"title":"Name"},"tagline":{"anyOf":[{"type":"string","maxLength":160},{"type":"null"}],"title":"Tagline"},"about":{"anyOf":[{"type":"string","maxLength":6000},{"type":"null"}],"title":"About"},"location":{"anyOf":[{"type":"string","maxLength":100},{"type":"null"}],"title":"Location"},"size":{"anyOf":[{"type":"string","enum":["1-10","11-50","51-200","201-500","501-1000","1001-5000","5000+"]},{"type":"null"}],"title":"Size"},"industry":{"anyOf":[{"type":"string","maxLength":80},{"type":"null"}],"title":"Industry"},"founded_year":{"anyOf":[{"type":"integer","minimum":1800.0},{"type":"null"}],"title":"Founded Year"},"website_url":{"anyOf":[{"type":"string","maxLength":300},{"type":"null"}],"title":"Website Url","description":"must be a http:// or https:// URL with a hostname and no credentials"},"linkedin_url":{"anyOf":[{"type":"string","maxLength":300},{"type":"null"}],"title":"Linkedin Url","description":"must be a http:// or https:// URL with a hostname and no credentials"},"x_url":{"anyOf":[{"type":"string","maxLength":300},{"type":"null"}],"title":"X Url","description":"must be a http:// or https:// URL with a hostname and no credentials"},"github_url":{"anyOf":[{"type":"string","maxLength":300},{"type":"null"}],"title":"Github Url","description":"must be a http:// or https:// URL with a hostname and no credentials"},"careers_url":{"anyOf":[{"type":"string","maxLength":300},{"type":"null"}],"title":"Careers Url","description":"must be a http:// or https:// URL with a hostname and no credentials"},"contact_email":{"anyOf":[{"type":"string","maxLength":320},{"type":"null"}],"title":"Contact Email"},"is_public":{"type":"boolean","title":"Is Public","default":false}},"type":"object","required":["handle","name"],"title":"CompanyUpsert","description":"The whole company page. ``PUT`` semantics: absent means cleared.\n\nA PUT and not a PATCH for the reason ``profiles.ProfileUpsert`` gives: this\nis a form, the editor holds every field on screen and submits all of them, so\n\"the body is the new state\" is both the honest description and the only one\nthat lets somebody delete their tagline without a null-versus-absent\nconvention every client then has to get right.\n\nThe sharp edge, stated plainly for whoever writes that client: **omitting\n``is_public`` unpublishes.** A partial body does not preserve the old\nvisibility.\n\n``name`` is the one required field besides the handle. A company with no name\nis a page that cannot render a heading, and unlike a person -- whose page can\nhonestly fall back to their handle -- an organisation choosing to be listed\nhas by definition decided what it is called."},"DefaultTierUpdate":{"properties":{"tier":{"type":"string","title":"Tier"}},"type":"object","required":["tier"],"title":"DefaultTierUpdate"},"DigestPrefsUpdate":{"properties":{"combined_digest":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Combined Digest"},"combined_digest_mode":{"anyOf":[{"type":"string","enum":["instant","hourly","6hourly","daily"]},{"type":"null"}],"title":"Combined Digest Mode"},"combined_digest_hour":{"anyOf":[{"type":"integer","maximum":23.0,"minimum":0.0},{"type":"null"}],"title":"Combined Digest Hour"}},"type":"object","title":"DigestPrefsUpdate","description":"Account-level delivery cadence: how often the ONE digest goes out.\n\nEvery field is optional so the UI can change the hour without also having to\nsend a cadence it did not ask the user about. ``combined_digest`` is still\naccepted for older clients and ignored: there is no per-agent mail any more, so\nthe account digest is always on."},"EducationIn":{"properties":{"school":{"type":"string","maxLength":120,"minLength":1,"title":"School"},"degree":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Degree"},"field_of_study":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Field Of Study"},"start_year":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Start Year"},"end_year":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"End Year"},"description":{"anyOf":[{"type":"string","maxLength":1000},{"type":"null"}],"title":"Description"}},"additionalProperties":false,"type":"object","required":["school"],"title":"EducationIn","description":"One school, as submitted.\n\nOnly ``school`` is required. A profile that says \"Cambridge\" and nothing\nelse is a true and useful statement, and a form that demands a degree before\nit will record where somebody studied is fighting its user.\n\nNo months, for the reason ``ProfileEducation`` gives: nobody writes them for\na degree, so the control would be empty on every entry."},"EmailUpdate":{"properties":{"notify_email":{"type":"string","maxLength":320,"minLength":3,"title":"Notify Email"}},"type":"object","required":["notify_email"],"title":"EmailUpdate"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HiringMessageIn":{"properties":{"subject":{"type":"string","maxLength":200,"minLength":3,"title":"Subject"},"body":{"type":"string","maxLength":4000,"minLength":20,"title":"Body"}},"type":"object","required":["subject","body"],"title":"HiringMessageIn"},"MarkApplied":{"properties":{"finding_id":{"type":"string","title":"Finding Id"}},"type":"object","required":["finding_id"],"title":"MarkApplied"},"PositionIn":{"properties":{"title":{"type":"string","maxLength":120,"minLength":1,"title":"Title"},"company":{"type":"string","maxLength":120,"minLength":1,"title":"Company"},"location":{"anyOf":[{"type":"string","maxLength":100},{"type":"null"}],"title":"Location"},"start_year":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Start Year"},"start_month":{"anyOf":[{"type":"integer","maximum":12.0,"minimum":1.0},{"type":"null"}],"title":"Start Month"},"end_year":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"End Year"},"end_month":{"anyOf":[{"type":"integer","maximum":12.0,"minimum":1.0},{"type":"null"}],"title":"End Month"},"description":{"anyOf":[{"type":"string","maxLength":1000},{"type":"null"}],"title":"Description"}},"additionalProperties":false,"type":"object","required":["title","company"],"title":"PositionIn","description":"One job, as submitted.\n\n``title`` and ``company`` are required and everything else is optional,\nwhich is the split between \"this is a job\" and \"this is what I remember\nabout it\". A CV with no dates is still a work history; a row with neither a\ntitle nor an employer is an empty entry somebody left behind in the editor,\nand storing it would put a blank card on their public page."},"PostCreate":{"properties":{"body":{"type":"string","maxLength":2000,"minLength":1,"title":"Body"}},"additionalProperties":false,"type":"object","required":["body"],"title":"PostCreate","description":"A new post. One field, because a post is one field.\n\n``extra=\"forbid\"`` so a client sending ``title`` or ``visibility`` -- both\nplausible, neither real -- is told rather than silently ignored."},"PostUpdate":{"properties":{"body":{"type":"string","maxLength":2000,"minLength":1,"title":"Body"}},"additionalProperties":false,"type":"object","required":["body"],"title":"PostUpdate","description":"An edit. Same single field, same rules."},"ProfileUpsert":{"properties":{"handle":{"type":"string","maxLength":30,"minLength":3,"title":"Handle"},"display_name":{"anyOf":[{"type":"string","maxLength":80},{"type":"null"}],"title":"Display Name"},"headline":{"anyOf":[{"type":"string","maxLength":160},{"type":"null"}],"title":"Headline"},"bio":{"anyOf":[{"type":"string","maxLength":2000},{"type":"null"}],"title":"Bio"},"location":{"anyOf":[{"type":"string","maxLength":100},{"type":"null"}],"title":"Location"},"company":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Company"},"role":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Role"},"open_to":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Open To"},"languages":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Languages"},"website_url":{"anyOf":[{"type":"string","maxLength":300},{"type":"null"}],"title":"Website Url"},"linkedin_url":{"anyOf":[{"type":"string","maxLength":300},{"type":"null"}],"title":"Linkedin Url"},"x_url":{"anyOf":[{"type":"string","maxLength":300},{"type":"null"}],"title":"X Url"},"github_url":{"anyOf":[{"type":"string","maxLength":300},{"type":"null"}],"title":"Github Url"},"work_email":{"anyOf":[{"type":"string","maxLength":320},{"type":"null"}],"title":"Work Email"},"phone":{"anyOf":[{"type":"string","maxLength":40},{"type":"null"}],"title":"Phone"},"calendar_url":{"anyOf":[{"type":"string","maxLength":300},{"type":"null"}],"title":"Calendar Url"},"skills":{"items":{"type":"string"},"type":"array","title":"Skills"},"positions":{"items":{"$ref":"#/components/schemas/PositionIn"},"type":"array","maxItems":20,"title":"Positions"},"education":{"items":{"$ref":"#/components/schemas/EducationIn"},"type":"array","maxItems":10,"title":"Education"},"contact_visibility":{"type":"string","enum":["open","paid","private"],"title":"Contact Visibility","default":"private"},"unlock_price_cents":{"anyOf":[{"type":"integer","maximum":10000.0,"minimum":10.0},{"type":"null"}],"title":"Unlock Price Cents"},"call_price_cents":{"anyOf":[{"type":"integer","maximum":200000.0,"minimum":500.0},{"type":"null"}],"title":"Call Price Cents"},"accepts_hiring_messages":{"type":"boolean","title":"Accepts Hiring Messages","default":true},"is_public":{"type":"boolean","title":"Is Public","default":false},"residence_country":{"anyOf":[{"type":"string","maxLength":2},{"type":"null"}],"title":"Residence Country"},"needs_sponsorship":{"anyOf":[{"type":"string","maxLength":8},{"type":"null"}],"title":"Needs Sponsorship"},"gender":{"anyOf":[{"type":"string","maxLength":16},{"type":"null"}],"title":"Gender"},"ethnicity":{"anyOf":[{"type":"string","maxLength":16},{"type":"null"}],"title":"Ethnicity"},"veteran_status":{"anyOf":[{"type":"string","maxLength":16},{"type":"null"}],"title":"Veteran Status"},"disability_status":{"anyOf":[{"type":"string","maxLength":16},{"type":"null"}],"title":"Disability Status"},"sexual_orientation":{"anyOf":[{"type":"string","maxLength":16},{"type":"null"}],"title":"Sexual Orientation"}},"type":"object","required":["handle"],"title":"ProfileUpsert","description":"The whole profile. ``PUT`` semantics: absent means cleared, not unchanged.\n\nA PUT and not a PATCH because this is a form. The editor holds every field on\nscreen and submits all of them, so \"the body is the new state\" is both the\nhonest description and the one that lets a user delete their headline —\nsomething a PATCH cannot express without a null-versus-absent convention that\nevery client then has to get right.\n\nThe consequence is worth stating plainly for whoever writes that client:\n**omitting ``is_public`` sets it to false.** A partial body does not preserve\nthe old visibility, it unpublishes."},"PublishRequest":{"properties":{"confirm_public":{"type":"boolean","title":"Confirm Public"}},"type":"object","required":["confirm_public"],"title":"PublishRequest","description":"Explicit consent to expose this agent's brief and findings."},"ReportCreate":{"properties":{"agent_id":{"type":"string","maxLength":64,"minLength":1,"title":"Agent Id"},"title":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Title"},"intro":{"anyOf":[{"type":"string","maxLength":4000},{"type":"null"}],"title":"Intro"},"selection":{"type":"string","enum":["high_intent","all","custom"],"title":"Selection","default":"high_intent"},"min_relevance":{"anyOf":[{"type":"integer","maximum":100.0,"minimum":0.0},{"type":"null"}],"title":"Min Relevance"},"finding_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Finding Ids"},"generate_summary":{"type":"boolean","title":"Generate Summary","default":true}},"type":"object","required":["agent_id"],"title":"ReportCreate"},"ReportUpdate":{"properties":{"title":{"anyOf":[{"type":"string","maxLength":200,"minLength":1},{"type":"null"}],"title":"Title"},"intro":{"anyOf":[{"type":"string","maxLength":4000},{"type":"null"}],"title":"Intro"}},"type":"object","title":"ReportUpdate","description":"Title and intro only.\n\nThe findings are not editable, by design. Letting the author swap the contents\nof a report that has already been shared would break the promise the share\nlink makes — the reader would be looking at something the author never sent.\nA different set of findings is a different report."},"RoleCreate":{"properties":{"title":{"type":"string","maxLength":160,"minLength":1,"title":"Title"},"description":{"anyOf":[{"type":"string","maxLength":12000},{"type":"null"}],"title":"Description"},"location":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Location"},"remote":{"anyOf":[{"type":"string","enum":["onsite","hybrid","remote"]},{"type":"null"}],"title":"Remote"},"employment_type":{"anyOf":[{"type":"string","enum":["full_time","part_time","contract","internship"]},{"type":"null"}],"title":"Employment Type"},"seniority":{"anyOf":[{"type":"string","enum":["intern","junior","mid","senior","staff","principal","exec"]},{"type":"null"}],"title":"Seniority"},"comp_min":{"anyOf":[{"type":"integer","maximum":100000000.0,"minimum":0.0},{"type":"null"}],"title":"Comp Min"},"comp_max":{"anyOf":[{"type":"integer","maximum":100000000.0,"minimum":0.0},{"type":"null"}],"title":"Comp Max"},"comp_currency":{"anyOf":[{"type":"string","maxLength":3},{"type":"null"}],"title":"Comp Currency"},"comp_period":{"anyOf":[{"type":"string","enum":["year","day","hour"]},{"type":"null"}],"title":"Comp Period"},"apply_url":{"anyOf":[{"type":"string","maxLength":300},{"type":"null"}],"title":"Apply Url","description":"must be a http:// or https:// URL with a hostname and no credentials"},"skills":{"items":{"type":"string"},"type":"array","title":"Skills"}},"type":"object","required":["title"],"title":"RoleCreate","description":"A new role. Created as a draft, always -- ``status`` is not a field here.\n\nPublishing is its own endpoint because it has its own precondition (the\ncompany page has to be public first) and its own side effect (a timestamp set\nonce). Letting ``POST`` accept ``status: \"open\"`` would be a second way to\npublish that skips both, and the one that skips them would be the one a script\nused."},"RolePatch":{"properties":{"title":{"anyOf":[{"type":"string","maxLength":160,"minLength":1},{"type":"null"}],"title":"Title"},"description":{"anyOf":[{"type":"string","maxLength":12000},{"type":"null"}],"title":"Description"},"location":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Location"},"remote":{"anyOf":[{"type":"string","enum":["onsite","hybrid","remote"]},{"type":"null"}],"title":"Remote"},"employment_type":{"anyOf":[{"type":"string","enum":["full_time","part_time","contract","internship"]},{"type":"null"}],"title":"Employment Type"},"seniority":{"anyOf":[{"type":"string","enum":["intern","junior","mid","senior","staff","principal","exec"]},{"type":"null"}],"title":"Seniority"},"comp_min":{"anyOf":[{"type":"integer","maximum":100000000.0,"minimum":0.0},{"type":"null"}],"title":"Comp Min"},"comp_max":{"anyOf":[{"type":"integer","maximum":100000000.0,"minimum":0.0},{"type":"null"}],"title":"Comp Max"},"comp_currency":{"anyOf":[{"type":"string","maxLength":3},{"type":"null"}],"title":"Comp Currency"},"comp_period":{"anyOf":[{"type":"string","enum":["year","day","hour"]},{"type":"null"}],"title":"Comp Period"},"apply_url":{"anyOf":[{"type":"string","maxLength":300},{"type":"null"}],"title":"Apply Url","description":"must be a http:// or https:// URL with a hostname and no credentials"},"skills":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Skills"}},"type":"object","title":"RolePatch","description":"A partial update. ``PATCH`` semantics: absent means UNCHANGED.\n\nThe opposite of ``PUT /api/company`` and ``PUT /api/profile``, and the\ndifference is not an inconsistency -- it follows from what the client is. Those\ntwo are one form holding one object, so \"the body is the new state\" is the\nhonest description. A company may hold two hundred roles and edits them from a\nlist, one field at a time; a PUT there would mean every \"close the salary\nrange\" interaction has to resend the entire brief, and the first client that\nforgets a field silently wipes it.\n\nAbsent versus null is therefore load-bearing, and it is read off\n``model_fields_set`` rather than by a sentinel value: absent means untouched,\nan explicit ``null`` means cleared. Every field is inherited from\n``RoleCreate``, including its validators, so the two bodies cannot drift --\nonly the requiredness changes.\n\n``status`` is absent here too. Publishing and closing have preconditions and\ntimestamps, and a settable status field would route around both."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"WorkCodeIn":{"properties":{"code":{"type":"string","maxLength":6,"minLength":6,"pattern":"^\\d{6}$","title":"Code"}},"type":"object","required":["code"],"title":"WorkCodeIn"},"WorkEmailIn":{"properties":{"email":{"type":"string","maxLength":320,"minLength":3,"title":"Email"}},"type":"object","required":["email"],"title":"WorkEmailIn"}}}}