Python SDK
A thin, typed client over the REST API. One requests.Session, resource namespaces that mirror the API, and real exception classes instead of status-code arithmetic.
Install
pip install lithoraRequires Python 3.9 or newer. The only dependency is requests. The CLI is built on this package, so the two share one HTTP core and behave identically.
Quickstart
Mint a personal access token at Settings → API Tokens, export it as LITHORA_TOKEN, and go.
import os
from lithora import Lithora
client = Lithora(token=os.environ["LITHORA_TOKEN"])
print(client.auth.me()["email"])
team = client.teams.list()[0]
tasks = client.tasks.list(project_id="8f1c0c2e-4a2d-4f5b-9c31-6b2a7e0d4411")
print(f"{len(tasks)} tasks in {team['name']}")Constructing the client
from lithora import Lithora
client = Lithora(
base_url="https://api.lithora.app", # default; use http://localhost:8000 for local dev
token=os.environ["LITHORA_TOKEN"], # a lth_pat_… token, or a login JWT
timeout=30, # seconds, per request
)Pass the bare host — the SDK appends the /api prefix itself, and normalises away a trailing slash or a trailing /api if you include one anyway. Calling an authenticated method with no token raises AuthError immediately rather than waiting for the server to reject it.
You can log in with a password instead, but handle the challenge case — login does not always return a token:
from lithora import Lithora, AuthChallengeError
client = Lithora()
try:
user = client.login("you@example.com", password)
except AuthChallengeError as exc:
if exc.requires_2fa:
... # prompt for the authenticator code
elif exc.requires_login_otp:
... # prompt for the emailed one-time codePrefer a token in anything unattended
Working with tasks
# Create
task = client.tasks.create(
"Retry failed Dodo webhooks",
project_id,
description="Add a dead-letter queue with exponential backoff.",
priority="high",
due_date="2026-08-15",
tags=["billing", "reliability"],
)
task_id = task["task_id"]
# Read
client.tasks.list(project_id) # every task in a project (subtasks nested)
client.tasks.my() # assigned to you
client.tasks.get(task_id)
# Update — these return {"message": ...}, not the task. Re-read if you need state.
client.tasks.set_status(task_id, "in_progress")
client.tasks.update(task_id, priority="urgent", assigned_to=user_id)
# Soft delete — moves to Trash, restorable
client.tasks.delete(task_id)The work graph
Relations are the reason to reach past the task API: they let you ask what blocks what, what belongs to what, and how long work actually takes end to end.
# Typed edges between anything: task, project, file, note, whiteboard, doc
client.work_items.create_relation(source_id, target_id, "blocking")
client.work_items.relations(item_id)
client.work_items.children(item_id)
# Analytics and live GitHub state
client.work_items.cycle_time(team_id=team_id)
client.work_items.pr_status(task_id)
client.work_items.graph(team_id=team_id, limit=200)Valid relation types are listed on client.work_items.RELATION_TYPES: parent_child, blocking, blocked_by, blocks, related_to, attached_to, belongs_to, caused_by, recurrence_of.
Driving the agent
The agent proposes; you dispose. A chat response with requires_confirmation means nothing has been written — the plan is staged and waiting.
session = client.ai.create_session(title="Nightly triage")
sid = session["session_id"]
# Give the agent context. It sees ONLY what you link.
client.ai.link_item(sid, "project", project_id)
reply = client.ai.chat(sid, "Which billing tasks are blocked, and why?")
if reply.get("requires_confirmation"):
plan = reply["action_plan"]
print(plan["summary"]) # show a human the plan
if approved_by_a_human(plan): # your call, your policy
client.ai.confirm_agent_action(sid, plan["action_id"], confirmed=True)
else:
client.ai.confirm_agent_action(sid, plan["action_id"], confirmed=False)
else:
print(reply["response"])Autonomous runs — overnight triage, CI-failure triage — stage plans the same way. Poll for them:
for plan in client.ai.list_pending_actions().get("pending", []):
print(plan["action_id"], plan.get("summary"))confirm_action() is the deprecated gate
confirm_agent_action(), which calls /api/ai-workspace/agent/confirm. The similarly named confirm_action() targets the legacy intent-parser endpoint and is kept only for backward compatibility.Errors
The hierarchy is deliberately shallow, so you can catch broadly or narrowly: LithoraError → ApiError → AuthError (401) and ServerError (5xx), plus AuthChallengeError for an incomplete login.
from lithora import ApiError, AuthError, LithoraError, ServerError
try:
client.tasks.create("Ship it", project_id)
except AuthError:
... # 401 — token missing, expired or revoked
except ServerError as exc:
... # 5xx — retry with backoff
except ApiError as exc:
if exc.status == 403:
... # missing scope, not a member, or a guest seat
elif exc.status == 422:
print(exc.detail) # {"errors": [{"field", "message", "type"}]}
else:
raise
except LithoraError:
... # catch-all for anything this SDK raisesEvery ApiError carries .status, .detail (parsed out of the API error envelope) and .response, so you can read X-Correlation-ID off the raw response when you need to file a support ticket.
The SDK retries for you, within limits. A 429, 502, 503 or 504 on an idempotent verb is retried up to max_retries times (3 by default), honouring Retry-After when the server sends it and falling back to capped exponential backoff when it does not. A RateLimitError therefore means the retries are already spent — it carries .retry_after. Two things are deliberately NOT retried: a 500, because a handler raised and sending the same request again gets the same exception plus a second side effect; and any POST, because there is no idempotency key yet and a retried create can create twice. Pass max_retries=0 to turn it off.
Resource namespaces
| Namespace | Methods |
|---|---|
| client.auth | me() |
| client.tokens | create(), list(), revoke() |
| client.teams | create(), list(), get(), members() |
| client.projects | create(), list(), get(), update(), patch(), delete() |
| client.tasks | create(), list(), my(), get(), update(), set_status(), delete(), bulk(), link_github(), push_to_github() |
| client.work_items | list(), get(), create(), update(), delete(), create_relation(), relations(), children(), parents(), delete_relation(), cycle_time(), graph(), pr_status(), resolve_ref() |
| client.automations | list(), create(), get(), toggle(), execute(), runs(), run_status(), export(), versions(), rollback(), templates() |
| client.github | status(), repos(), connect_url() |
| client.search | query(), recent(), suggestions() |
| client.ai | create_session(), chat(), link_item(), search(), list_sessions(), get_session(), confirm_agent_action(), list_pending_actions() |
| client.runners | create(), list(), revoke(), update_manifest(), dispatch(), jobs(), audit() |
| client.sprints | list(), create(), plan(), velocity(), get(), update(), close(), summary() |
| client.webhooks | list(), create(), event_types(), get(), update(), delete(), logs(), clear_logs(), stats(), regenerate_secret(), test() |
| client.data | fields(), export(), import_tasks(), import_entity() |
Methods that update a resource accept arbitrary keyword arguments and pass them straight through, so the SDK never silently drops a field the server accepts. See the API reference for the underlying request and response shapes.
There is no client.register(). It used to exist and to target a route that had been removed from the API for an account-takeover defect, so every call returned a 404 whose message explained nothing. Accounts are created only through the email-OTP signup flow in the web app.
tasks.list() and work_items.list()take the server’s own filters — status, limit, offset on the first; types, team_id, project_id, parent_id, limit, skip on the second. Filter there rather than in your own code: what a list call returns is a PAGE, so filtering after the fact gives you the matches within one page and no indication that anything was left out.