Skip to main content

Lemma Python SDK

lemma-sdk is the Python client library for Lemma. It wraps a generated OpenAPI client with a pod-first, ergonomic surface for tables, files, functions, agents, workflows, schedules, surfaces, apps, and connectors.

It is the same SDK that runs inside Lemma functions (where the runtime injects auth automatically) and in standalone application code (where you supply a token). The CLI and TUI live in the sibling lemma-cli package.

  • package name: lemma-sdk
  • import root: lemma_sdk
  • Python >=3.14,<3.15 (uv recommended)

Reading the source. In a Lemma sandbox the full SDK source is available at /sdk/lemma-python (and the TypeScript SDK at /sdk/lemma-typescript). When you need an exact signature or response shape, read it rather than guessing — e.g. cat /sdk/lemma-python/lemma_sdk/resources/data.py.

Install

The published package is lemma-sdk, not lemma-python — that is the directory it is built from.

uv add lemma-sdk            # or: pip install lemma-sdk

From a checkout, for working on the SDK itself:

uv pip install .            # or: uv pip install --editable .
python -c "from lemma_sdk import Pod, Lemma; print(Pod, Lemma)"

This package requires Python 3.14 (requires-python = ">=3.14,<3.15"), which is not what python3 is on current distributions or on macOS. Check before you install, because the failure is quiet: on an older interpreter neither pip nor uv errors — the resolver walks back to 0.6.2, the last release that allowed 3.11, and installs that instead. You get an SDK two minors behind the API you are calling and nothing says so. Confirm what you actually got with python -c "import importlib.metadata as m; print(m.version('lemma-sdk'))".

uv will provision 3.14 for you:

uv venv --python 3.14 && uv pip install lemma-sdk   # standalone environment

In a uv project, put requires-python = ">=3.14" in your pyproject.toml before uv add lemma-sdk; otherwise the project's own floor is what makes the resolver reach for the old release.

The lemma CLI is unaffected by any of this: uv tool install lemma-terminal provisions its own interpreter, so the CLI works whatever python3 on your machine happens to be.

Two entry points

Class Scope Use for
Pod one pod almost everything — data, files, functions, agents, workflows, app operations
Lemma org / global org & pod discovery, org-level connector setup, tools, runtime profiles
from lemma_sdk import Pod, Lemma

pod = Pod.from_env()                    # token + pod id from env / CLI session
lemma = Lemma.from_env(org_id="org-id") # org-scoped client; lemma.pod("id") -> Pod

Pod is a context manager and owns an HTTP transport when constructed directly:

with Pod.from_env() as pod:
    pod.functions.run("triage_ticket", {"ticket_id": "rec-1"})

lemma.pod(...) and lemma.for_org(...) are views of the client they come from: same endpoint, same credential, same connection pool, so closing the parent closes everything. Only a directly constructed Pod/Lemma owns a transport of its own.

Every call is synchronous

There is no async client. Each method makes a blocking HTTP request, and a retried request sleeps (up to a few seconds) on the calling thread. That is what you want in a script, a function handler doing one thing at a time, or a worker thread — and it is not what you want on an event loop. From async def code that also serves other work, hand each call to a thread:

import asyncio

row = await asyncio.to_thread(pod.table("tickets").get, ticket_id)

# Fanning out? to_thread + gather runs the round trips concurrently; a plain
# loop of SDK calls serializes them and freezes the loop for the whole batch.
rows = await asyncio.gather(*(
    asyncio.to_thread(pod.table("tickets").get, tid) for tid in ticket_ids
))

Authentication & configuration

The SDK resolves settings from explicit arguments first, then environment, then the CLI config file (~/.lemma/config.json).

Environment variables:

export LEMMA_TOKEN="<access-token>"      # required if not using a CLI session
export LEMMA_POD_ID="<pod-id>"           # required for Pod.from_env()
export LEMMA_ORG_ID="<org-id>"           # required for org-scoped calls
export LEMMA_BASE_URL="https://api.lemma.work"
export LEMMA_AUTH_URL="https://lemma.work/auth"
export LEMMA_REFRESH_TOKEN="<refresh-token>"     # optional
export LEMMA_CONFIG_FILE="~/.lemma/config.json"  # optional override
export LEMMA_SSL_NO_VERIFY=1              # local/self-signed only

Inside a Lemma function, LEMMA_TOKEN (a workload token scoped to the function's grants) and LEMMA_POD_ID are injected for you — just call Pod.from_env().

Explicit construction (no env needed):

pod = Pod(pod_id="pod-id", org_id="org-id", token="token",
          base_url="https://api.lemma.work")

When LEMMA_TOKEN is unset, settings fall back to the selected server in the CLI config:

{
  "active_server": "cloud",
  "servers": {
    "cloud": {
      "base_url": "https://api.lemma.work",
      "auth_url": "https://lemma.work/auth",
      "defaults": { "org_id": "org-id", "pod_id": "pod-id" }
    }
  }
}

(Legacy active_context / contexts keys are still accepted and translated.) Install and manage the CLI from lemma-cli; see lemma-cli/SETUP.md.

Response shapes — read this first

Every method returns a typed response object. Call .to_dict() for plain data, then unwrap:

Call .to_dict() returns Rows under
records.create / get / update the bare record object (no envelope) top-level
table.create / get / update the table detail object top-level
records.list, table.list {"items": [...], "total": N, "limit": N, "next_page_token": ...} ["items"]
bulk_create / bulk_update / bulk_delete {"count": N} ["count"]
query(sql) {"items": [...], "total": N} ["items"]
connectors.execute {"result": ...} ["result"]
functions.run {"status": ..., "output_data": ..., "logs": ...} top-level

Single-record create/get/update return the record directly — there is no {"data": {...}} envelope. Call .to_dict() and use the result as the row. The pod.records helpers already unwrap to a plain dict for you. The bulk helpers return the integer count directly.

Pod facades

pod.tables · pod.records · pod.queries · pod.files · pod.functions · pod.agents · pod.workflows · pod.schedules · pod.conversations · pod.members · pod.apps · pod.surfaces · pod.connectors

Plus helpers: pod.table(name) (bound single-table helper), pod.query(sql), pod.generated (raw OpenAPI client escape hatch).

Tables & records — full CRUD

t = pod.table("tickets")

row = t.create({"title": "Refund", "status": "new"})   # already a plain dict
ticket_id = row["id"]

row = t.get(ticket_id)                            # bare record dict, no envelope
t.update(ticket_id, {"status": "resolved"})       # only passed fields change
t.delete(ticket_id)

# Writing more than a row or two? Use the batch form -- one round trip instead
# of N. A loop of t.create(...) pays a full request per row.
t.bulk_create([{"title": f"Refund {i}", "status": "new"} for i in range(50)])

rows = pod.records.list(
    "tickets", limit=50,
    filter=[
        {"field": "status", "op": "eq", "value": "new"},
        {"field": "priority", "op": "ne", "value": "low"},
    ],
    sort=[{"field": "created_at", "direction": "desc"}],
).to_dict()["items"]

# `list` returns one page — `limit` defaults to 20 and the rest is behind
# `next_page_token`. When the answer has to be complete, page to exhaustion:
every_open = pod.records.list_all(
    "tickets", filter=[{"field": "status", "op": "eq", "value": "new"}]
)   # -> list of plain row dicts; t.list_all() is the same walk for one table

totals = pod.query(
    "select status, count(*) as total from tickets group by status"
).to_dict()["items"]

The pod.records / pod.table(...) create/get/update helpers return the bare record as a plain dict (no .to_dict(), no ["data"] unwrap). list and query return response objects; call .to_dict() and read ["items"]; list_all returns the rows themselves.

Record data is dynamic because table schemas are user-defined.

RLS vs shared tables

Tables carry an enable_rls flag that defaults to true (row-level security on). With RLS on, each row is owned by its creator: non-admin members read/update/delete only their own rows (other users' rows are invisible — cross-user access returns 404), while pod admins see and manage every row. This is the right default for per-user/personal data.

Set enable_rls: false for SHARED/reference/team tables that all members should see and mutate. RLS only scopes which rows a non-admin can touch — it does not change the permission a write needs: writing any table requires the DATASTORE_RECORD_WRITE permission (POD_USER and above), RLS or not. The read-only query endpoint can join across tables only when they are non-RLS.

Bulk record operations

Reach for these whenever you write more than a couple of rows: each one is a single request, where a loop of create is one request per row. The same three methods exist on the bound helper — pod.table("tickets").bulk_create(rows) — so you never have to leave the table handle to get a batch.

# create: row dicts (ids generated)
created_count = pod.records.bulk_create("ticket_events", [
    {"ticket_id": ticket_id, "kind": "created"},
    {"ticket_id": ticket_id, "kind": "triaged"},
])

# update: FLAT dicts that MUST include the primary key
updated_count = pod.records.bulk_update("tickets", [
    {"id": id_a, "status": "resolved"},
    {"id": id_b, "status": "waiting", "priority": "urgent"},
])

# delete: list of primary-key values
deleted_count = pod.records.bulk_delete("tickets", [id_a, id_b])

Files — searchable documents (built-in RAG)

Files uploaded to a pod are automatically indexed: text is extracted, chunked, and embedded, so they become searchable with no separate vector DB or infra. That makes files the pod's built-in retrieval-augmented generation store.

Only document formats are indexed: PDF, DOC/DOCX, ODT, RTF, Markdown, plain text, HTML, EPUB. Data/binary formats (CSV, TSV, JSON, YAML, XLSX, images, email) are stored but not indexed (status NOT_REQUIRED) and never appear in search — so keep structured data in tables and prose/documents in files. search_enabled toggles indexing per file; status flows PENDING → PROCESSING → COMPLETED (searchable) / NOT_REQUIRED / FAILED, and only COMPLETED documents are searchable. Documents are also converted to markdown.

/me is each user's private per-user tree (only the owner sees their /me files); all other paths are pod-shared, and folder grants cascade to every descendant.

pod.files.create_folder("/reports", description="Generated reports")
pod.files.upload("/tmp/summary.md", directory_path="/reports")

# Plain search (defaults to the whole pod):
hits = pod.files.search("refund policy").to_dict()

# Directory-scoped RAG + method selection:
hits = pod.files.search(
    "refund policy",
    scope_path="/knowledge",     # restrict to a folder
    scope_mode="SUBTREE",        # SUBTREE = folder + all descendants (default); DIRECT = immediate children only
    search_method="HYBRID",      # TEXT (full-text), VECTOR (semantic), or HYBRID
).to_dict()

md  = pod.files.download_markdown("/knowledge/policy.pdf")           # converted markdown bytes
kids = pod.files.list_children("/knowledge/policy.pdf")              # derived child files (md, figures, pages)
raw = pod.files.download("/knowledge/policy.pdf")                     # bytes

Functions, agents, workflows

run = pod.functions.run("triage_ticket", {"ticket_id": "rec-1"}).to_dict()
# run["status"], run["output_data"], run["logs"]

agent = pod.agents.get("triage").to_dict()
conv = pod.conversations.create_for_agent("triage", title="Triage")
pod.conversations.send(str(conv.to_dict()["id"]), "Classify ticket rec-1")

wf_run = pod.workflows.create_run("nightly_review").to_dict()
# Workflow inputs are collected by FORM nodes mid-run, not at start; submit them with
# pod.workflows.submit_form(wf_run["id"], node_id="<form_node>", inputs={"limit": 10})

Connectors (calling external apps)

pod.connectors.execute(auth_config, operation, payload) runs a third-party operation. The first argument is the auth config name (often the app id), the operation id and payload come from discovery, and the response is under ["result"].

sent = pod.connectors.execute(
    "workspace-gmail",          # auth config name
    "GMAIL_SEND_EMAIL",         # operation id from discovery
    {"recipient_email": "a@example.com", "subject": "Hi", "body": "..."},
).to_dict()["result"]

# discover before you call:
matches = pod.connectors.operations.search("workspace-gmail", "send email")
schema  = pod.connectors.operations.get("workspace-gmail", "GMAIL_SEND_EMAIL")

Operation ids and payload keys differ between the lemma and composio providers — confirm with discovery for the provider your org installed. Don't pass account_id unless pinning a specific account; the backend resolves the fixed or invoking-user account from the token.

Org & global usage (Lemma)

lemma = Lemma.from_env(org_id="org-id")

org   = lemma.org.get()
pods  = lemma.pods.list()
pod   = lemma.pod("pod-id")          # -> Pod sharing this transport
me    = lemma.user.profile()

# org-level connector setup
auth_configs = lemma.connectors.auth_configs.list()
accounts     = lemma.connectors.accounts.list(app="gmail")

# first-party tools
results = lemma.tools.web_search("vendor SLA policy", max_results=5)

Facades: lemma.orgs · lemma.org · lemma.pods · lemma.user · lemma.connectors · lemma.tools · lemma.agent_hosts · lemma.org_runtime.

Writing a function

A Lemma function is a Python file with header comments declaring its types, plus a handler (ctx, data) -> output:

#input_type_name: TriageInput
#output_type_name: TriageResult
#function_name: triage_ticket

from pydantic import BaseModel
from lemma_sdk import FunctionContext, Pod

class TriageInput(BaseModel):
    ticket_id: str

class TriageResult(BaseModel):
    status: str

async def triage_ticket(ctx: FunctionContext, data: TriageInput) -> TriageResult:
    pod = Pod.from_env()    # authenticated as this function's workload principal
    pod.table("tickets").update(data.ticket_id, {"status": "triaged"})
    return TriageResult(status="triaged")

FunctionContext fields: pod_id, function_id, user_id, user_email, config. The function runs with zero default access — grant it the tables, folders, and apps it touches (see the lemma-builder skill / lemma functions permissions).

Typed models

lemma_sdk.models re-exports the common response types with friendly names:

from lemma_sdk.models import Record, FunctionRun, OperationExecution, Agent, Function

record: Record = pod.records.create("tickets", {"title": "Typed"})

Generated request models live under lemma_sdk.openapi_client.models (e.g. CreateTableRequest) for endpoints you build payloads for by hand.

Errors

from lemma_sdk import LemmaAPIError, LemmaConfigError

try:
    pod.records.get("tickets", "missing")
except LemmaAPIError as e:
    print(e.status_code, e.code, e.message)
except LemmaConfigError:
    ...   # missing token / pod id / unreadable config

Generated client escape hatch

For endpoints not yet wrapped by the ergonomic SDK:

generated = pod.generated   # authenticated; same base URL/token/timeout/SSL

Testing against a real Lemma API

Unit tests cover wrapper behavior; an opt-in integration scenario runs real end-to-end work against a running API. Start the local stack from the repo root:

make dev

Then from lemma-python:

export LEMMA_TOKEN="<access-token>"
LEMMA_RUN_CONNECTOR=1 uv run --with pytest --with pytest-asyncio \
  pytest tests/integration -m integration -s

This development-only scenario defaults to the fixed API port used by make dev; a packaged Desktop installation instead discovers its dynamic endpoint through locald. The scenario falls back to the CLI auth session if LEMMA_TOKEN is unset. Point elsewhere with LEMMA_CONNECTOR_BASE_URL / LEMMA_CONNECTOR_TOKEN. The scenario creates a fresh org and pod, exercises tables/records/query/files/functions/agents/ workflows/connectors, prints a summary, and deletes the pod.

Regenerate the SDK

Run the backend locally, then regenerate from its OpenAPI spec:

bash scripts/generate_openapi_client.sh
OPENAPI_URL=http://127.0.0.1:8000/openapi.json OPENAPI_INSECURE=1 \
  bash scripts/generate_openapi_client.sh

Generator env vars: LEMMA_API_URL, OPENAPI_URL, OPENAPI_INSECURE, LEMMA_SSL_NO_VERIFY.

Development checks

uv run ruff check lemma_sdk tests
uv run --with pytest python -m pytest tests

Metadata

Release files for lemma-sdk 0.8.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for lemma-sdk 0.8.0
File Size Uploaded
lemma_sdk-0.8.0.tar.gz 456.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for lemma-sdk 0.8.0
File Interpreter ABI Platform
lemma_sdk-0.8.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.5 MB

Release files / lemma_sdk-0.8.0.tar.gz

Download URL lemma_sdk-0.8.0.tar.gz
Size 456.9 kB
Tags Source
SHA-256 checksum
How to use checksums
70a43f237016063bbe63bae78140fa6858599575f1b3ca0f8a787f6918f56351
BLAKE2b-256 checksum
How to use checksums
8091a75b5fc2c855c157e0d7fd7d87b9368c29b8e1b3f5a3ccce1ebd69055773
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.7

Release files / lemma_sdk-0.8.0-py3-none-any.whl

Download URL lemma_sdk-0.8.0-py3-none-any.whl
Size 1.1 MB
Tags Python 3
SHA-256 checksum
How to use checksums
306a9f6ecd1be015ba047284c8dd8f7f8ef4106cf832ca5355375ed263ae062a
BLAKE2b-256 checksum
How to use checksums
4295ce2827f90b91c19d9954f6ec5960a21b783122a287f04af4a57cd679707f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.7

Release history Release notifications | RSS feed

0.9.0

2 release files

This release

0.8.0 This release

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.6.2

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.4

2 release files

0.5.3

2 release files

0.5.0

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.3

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page