Skip to content

Client reference

Generated from the SDK docstrings. Every operation lives on a namespace off Chronicle; the resource handles (Experiment, Variation, Run, Report) are sugar for chained drill-downs. For task-oriented walkthroughs, start with the Guide.

Top-level

methodic.Chronicle

Chronicle(
    server_url: str,
    api_key: str,
    timeout: int = 30,
    max_upload_workers: int = 2,
    organization_id: str | None = None,
    organization_slug: str | None = None,
)

Client for the Chronicle REST API.

Construct once with the server URL + API key, then call into namespaces:

chronicle = Chronicle(server_url="https://api.methodiclabs.ai", api_key="sk_...")

Or resolve credentials from the environment / a config file (see from_env and from_file) — the form the Chronicle skills use:

chronicle = Chronicle.from_env()  # CHRONICLE_SERVER_URL + CHRONICLE_API_KEY

Then call into namespaces:

# Researcher
exp = chronicle.experiments.create(hypothesis_summary="...", config_yaml="...")
exp.commit().variations.create(config_yaml="...")

# Worker
run = chronicle.run(experiment_id, variation, run_idx)
run.start().heartbeat()
run.upload_asset(asset_type="research_report", content={"summary": "..."})
run.succeed()

Use as a context manager to guarantee the executor and HTTP session are closed.

Construct via :meth:from_env (reads CHRONICLE_SERVER_URL + CHRONICLE_API_KEY) or directly with server_url + api_key.

Organization scope resolves per call, with an optional client default: pass organization_id on the call itself (e.g. experiments.create(..., organization_id=...)), or set a default once — organization_id: in ~/.methodic/config.yaml or $CHRONICLE_ORGANIZATION_ID — and omit it. With a default configured, pass methodic.PERSONAL to force a personal-scope call. There is no per-request header; resolution happens in the SDK call.

organization_id property

organization_id: str | None

The default organization for calls that take an organization_id and were not given one (None = personal scope).

When configured by slug (organization_slug / the CHRONICLE_ORGANIZATION_SLUG env / the organization_slug: config key), the slug is resolved to its principal id via /v1/me/scopes on first read and cached for the client's lifetime; a slug matching no organization you belong to raises ChronicleConfigError.

close

close() -> None

Shut down the upload pool and HTTP session. Idempotent.

experiment

experiment(experiment_id: str) -> Experiment

Get a handle for an existing experiment by id (lazy — no fetch until accessed).

from_env classmethod

from_env(**overrides: Any) -> Chronicle

Construct a client from the ambient environment.

Resolution order, highest precedence first:

  1. explicit keyword arguments — server_url, api_key, timeout, max_upload_workers, organization_id
  2. environment variables — CHRONICLE_SERVER_URL, CHRONICLE_API_KEY, CHRONICLE_TIMEOUT, CHRONICLE_MAX_UPLOAD_WORKERS, CHRONICLE_ORGANIZATION_ID
  3. YAML files under ~/.methodicconfig.yaml (non-secret settings) then credentials.yaml (the API key); or a single file pointed to by CHRONICLE_CONFIG
  4. built-in defaults (server_urlhttps://api.methodiclabs.ai)

api_key has no default; raises ChronicleConfigError if it cannot be resolved from any source, or if a setting is malformed.

This is the entry point the Chronicle skills call, so setting either the environment variables or the config file is enough to run them.

from_file classmethod

from_file(
    config: str | Path | None = None,
    credentials: str | Path | None = None,
    **overrides: Any,
) -> Chronicle

Construct a client from explicit YAML files.

Mirrors the ~/.methodic split so the same files work with either entry point: a non-secret config file and/or a credentials file holding the API key. Each is a flat mapping::

# config.yaml
server_url: https://api.methodiclabs.ai
timeout: 30              # optional
# credentials.yaml
api_key: sk_user_...

Either may carry any subset (a single combined file can be passed as config); credentials wins over config on overlap, and explicit keyword arguments win over both. Unlike from_env, this does not consult environment variables. Raises ChronicleConfigError if a named file is missing or malformed, or if no api_key is present.

run

run(experiment_id: str, variation: int, run: int) -> Run

Construct a Run resource handle bound to one (experiment, variation, run).

variation

variation(experiment_id: str, variation: int) -> Variation

Get a handle for an existing variation by (experiment_id, variation).

Resource handles

methodic.Experiment

Experiment(
    chronicle: Chronicle,
    experiment_id: str,
    *,
    _detail: ExperimentDetail | None = None,
    _create_response: CreateExperimentResponse
    | None = None,
)

Handle for one experiment.

Mutators (commit, conclude, retract) return self so callers can chain (exp.commit().variations.create(...)). Cached detail is dropped after each mutation; the next attribute access re-fetches transparently.

commit

commit(
    *, tentative_links: dict[str, str] | None = None
) -> Experiment

Commit (lock the spec). Pass tentative_links (parent id → "promote"/"drop") to resolve any tentative parent links, which otherwise block commit. See :meth:ExperimentsAPI.commit.

delete

delete() -> dict[str, Any]

Hard-delete this open experiment (see :meth:ExperimentsAPI.delete). The handle is dead after this returns — the experiment row and its cascade are gone.

distill

distill(
    *,
    scope: str,
    variation_id: int | None = None,
    corpus_filter: dict[str, Any] | None = None,
    write_research_report: bool = True,
    reason: str | None = None,
) -> dict[str, Any]

Trigger a distillation agent for this experiment (M9 §17).

fork

fork(
    *,
    hypothesis_summary: str,
    rationale: str | None = None,
    config_yaml: str | None = None,
    slug: str | None = None,
    allow_retracted_parent: bool = False,
) -> "Experiment"

Fork this experiment. Proxies to ExperimentsAPI.fork.

get_agent_config

get_agent_config() -> dict[str, Any]

Read this experiment's agent_config block (M11).

git_status

git_status() -> GitStatus

Lightweight current git-integration state for this experiment.

mint_git_token

mint_git_token() -> GitToken

Mint a 1-hour install token scoped to this experiment's repo.

move

move(
    *,
    organization_id: str,
    team_id: str | None = None,
    visibility: str | None = None,
) -> Experiment

Transfer this experiment into an org (see :meth:ExperimentsAPI.move). Drops cached detail and returns self so calls can chain.

promote_lineage

promote_lineage(parent_id: str) -> dict[str, Any]

Promote a tentative parent link to real lineage (parent must have committed). See :meth:ExperimentsAPI.promote_lineage.

record_finding

record_finding(
    *,
    status: str,
    summary: str,
    evidence_variation: int,
    evidence_run: int | None = None,
    source_asset_id: str | None = None,
) -> dict[str, Any]

Record a what's-working / what's-not finding onto this experiment's running summary. Proxies to :meth:ExperimentsAPI.record_finding; invalidates the cached detail so running_summary re-fetches.

set_agent_config

set_agent_config(config: dict[str, Any]) -> dict[str, Any]

Replace this experiment's agent_config block.

set_continuous_exploration

set_continuous_exploration(
    *,
    enabled: bool,
    trigger_scope: str = "variation",
    cooldown_minutes: int = 0,
) -> dict[str, Any]

Configure the M11 continuous-exploration loop on this experiment. Convenience wrapper that read-modify-writes agent_config.continuous_exploration.

set_report_settings

set_report_settings(settings: dict[str, Any]) -> Experiment

Replace experiment.report_settings. Frozen at commit (server returns 409 once committed). settings shape matches the ReportSettings server type:

{
    "hypothesis": {"mode": "freeform", "freeform_prompt": "..."},
    "takeaways":  {"mode": "template", "template_asset_id": "...", "per_variation": true},
    "research":   {...}
}

Returns self for chaining. Drops cached _detail so the next access re-fetches the updated row.

tentative_links() -> list[TentativeParentLink]

This experiment's tentative parent lineage links (empty for the common case). See :meth:ExperimentsAPI.tentative_links.

wait_for_repo

wait_for_repo(
    *, timeout: float = 300.0, poll_interval: float = 2.0
) -> GitStatus

Poll until this experiment's repo is ready (or failed/timeout).

methodic.Variation

Variation(
    chronicle: Chronicle,
    experiment_id: str,
    variation: int,
    *,
    _data: Variation | None = None,
)

Handle for one variation. Holds (experiment_id, variation) and lazy-loaded data.

data property

data: Variation

Server-side variation record. Auto-fetched on first access; refetched after mutations.

list_inputs

list_inputs() -> list[dict[str, Any]]

List this variation's input assets (raw dicts).

list_outputs

list_outputs() -> list[dict[str, Any]]

List this variation's output assets across all runs (raw dicts), newest-first — produced checkpoints, snapshots, and reports.

set_git_ref

set_git_ref(git_ref: str) -> Variation

Bind a branch to this (open) variation. Returns self for chaining.

unlink_input(asset_id: str) -> Variation

Unlink an input asset from this (open) variation. Returns self for chaining.

update

update(
    *,
    hypothesis: str | None = None,
    expected_outcome: str | None = None,
    description: str | None = None,
    name: str | None = None,
) -> Variation

Edit this (open) variation's mutable metadata. Returns self for chaining.

methodic.Run

Run(
    api: RunsAPI,
    experiment_id: str,
    variation: int,
    run: int,
)

Handle for a specific (experiment_id, variation, run) run.

Mutators return self so worker code can chain (run.start().heartbeat()). Asset-upload helpers auto-populate output_of from the bound triple.

create_asset_presigned

create_asset_presigned(
    asset_type: str,
    components: list[str],
    name: str | None = None,
    content_type: str = "application/octet-stream",
) -> AssetUploadInfo

Register a new asset for component upload via presigned URLs.

latest_output

latest_output(
    asset_type: str | None = None,
    *,
    across_runs: bool = True,
    ready_only: bool = True,
) -> dict[str, Any] | None

Return the most recent matching output asset, or None — the resume-discovery helper.

Filters by asset_type (e.g. "checkpoint") and, by default, to state == "ready" (finalized + immutable). Searches across all runs of the variation by default. Pair with :meth:download_asset::

ckpt = run.latest_output("checkpoint")
if ckpt:
    run.download_asset(ckpt["id"], Path("./resume"))

list_outputs

list_outputs(
    *, across_runs: bool = False
) -> list[dict[str, Any]]

List this run's output assets (newest-first). With across_runs=True, list outputs across all runs of the variation — the scope to use when resuming, since the checkpoint to resume from was produced by an earlier run.

register_and_upload_async

register_and_upload_async(
    local_dir: Path,
    asset_type: str,
    upload_tracker: UploadTracker,
    content_type: str = "application/octet-stream",
) -> str

Register every file in a directory, then upload + finalize on a background thread.

succeed

succeed() -> Run

Mark the run succeeded after waiting for any pending async uploads.

upload_asset

upload_asset(
    asset_type: str,
    content: Any,
    name: str | None = None,
    content_type: str = "application/json",
    asset_config: dict[str, Any] | None = None,
) -> dict[str, Any]

Upload a small inline asset (auto-finalized). Linked to this run as output.

upload_directory_async

upload_directory_async(
    local_dir: Path,
    asset_type: str,
    content_type: str = "application/octet-stream",
    upload_tracker: UploadTracker | None = None,
) -> None

Upload every file in a directory on a background thread.

With upload_tracker, uses the register-then-upload flow for crash recovery. Without, a thinner path that uploads and finalizes inline.

methodic.Report

Report(
    chronicle: Chronicle,
    *,
    asset_dict: dict[str, Any] | None = None,
    full_dict: dict[str, Any] | None = None,
)

Handle for one rendered report asset.

Wraps the asset metadata plus (when fetched via reports.get or refresh) the json_asset_store content with compile_status, log, and pdf_b64. Mutators return self for chaining.

compile_log property

compile_log: str | None

Tectonic stdout/stderr — only available when content has been fetched (via refresh() or chronicle.reports.get(...)).

compile_status property

compile_status: str

Current compile_status: pending / compiled / failed / stale.

Reads from the asset's asset_config.compile_status (always present on render-pipeline-produced assets); for content-fetched reports also cross-references the json_asset_store body.

download_pdf

download_pdf(path: str | None = None) -> bytes

Download the compiled PDF. If path is given, write it there. Returns the PDF bytes regardless. Raises RuntimeError if the compile hasn't succeeded or no PDF is available.

Will fetch content if not already loaded (i.e. one extra round trip when called on a Report obtained from render rather than get).

download_source

download_source() -> str

Return the rendered .tex source. Loads content on first call.

refresh

refresh() -> Report

Re-fetch the report's asset + content. Returns self for chaining (e.g. report.refresh().compile_status).

wait_for_compile

wait_for_compile(
    *, timeout: float = 60.0, poll_interval: float = 1.0
) -> Report

Poll refresh until compile_status is terminal (compiled, failed, stale) or timeout elapses. Returns self; check .compile_status on the result.

On most deployments the render endpoint compiles synchronously, so this returns immediately on the first refresh. Useful for deployments where the compile worker is configured to run out-of-band (or for the M4-style stub where compile_status starts at pending and never advances — in that case this times out).

Namespaces

methodic.ExperimentsAPI

ExperimentsAPI(transport: Transport, chronicle: Chronicle)

Experiments namespace. Stateless; every method takes the experiment id explicitly.

commit

commit(
    experiment_id: str,
    *,
    tentative_links: dict[str, str] | None = None,
) -> dict[str, Any]

Commit (lock the spec of) an experiment.

If the experiment has tentative parent lineage links (from being created/forked off a still-open parent — see :meth:tentative_links), every one must be resolved here or commit returns 409: pass tentative_links mapping each parent experiment id to "promote" (make it real lineage — only valid once the parent has committed) or "drop" (discard the link). Naming a parent that isn't a tentative link, or omitting one that is, is rejected — there is no silent default.

conclude

conclude(
    experiment_id: str,
    *,
    on_exist_action: str | None = None,
) -> dict[str, Any]

Conclude an experiment: lock all outputs, no new variations. Terminal.

Not idempotent — re-concluding a concluded experiment raises (409 already_concluded). Gates on a satisfying experiment-level takeaways_report; if none exists the server schedules a distillation and the 409 body carries distillation_job_id to poll, then re-issue conclude.

Parameters:

Name Type Description Default
experiment_id str

Target experiment.

required
on_exist_action str | None

"keep" or "regenerate" — required by the server (else 409 takeaways_report_exists) when an experiment-level takeaways_report already exists. keep gates on the existing (approved) report; regenerate supersedes it and distills a fresh one.

None

create

create(
    *,
    hypothesis_summary: str,
    config_yaml: str,
    rationale: str | None = None,
    description: str | None = None,
    accelerate_config_yaml: str | None = None,
    launch_config: dict[str, Any] | None = None,
    parent_experiment_ids: list[str] | None = None,
    allow_retracted_parent: bool = False,
    organization_id: str | None = None,
    team_id: str | None = None,
    visibility: str | None = None,
) -> Experiment

Create a new experiment. Returns a handle with the create-response cached.

Organization scope: pass organization_id (and/or team_id) to create the experiment under that org/team. Omitting it falls back to the client's configured default org (the organization_id setting — see Chronicle); with a default configured, pass methodic.PERSONAL for a personal experiment owned by the calling key's user.

visibility controls who can read the experiment and its reports: "private" (creator + org admins only), "organization" / "team" (the owning org or team gets read + discuss, and its reports become discoverable in search), or "public" (anyone, read-only). Omit it for the scope-derived default — org/team-wide in an org context, private in personal space.

delete

delete(experiment_id: str) -> dict[str, Any]

Hard-delete an open (uncommitted) experiment and everything it owns (variations, runs, asset/research-prompt links, ACLs, auto-roles, and best-effort the GitHub repo + search doc). Returns the server's removal summary.

Refused with ConflictError (409) once the experiment is committed or concluded — retract it instead — or if another experiment was derived from it (remove the descendants first). Requires the Delete action. The underlying asset rows survive (they may be shared across experiments); only this experiment's link rows go.

distill

distill(
    experiment_id: str,
    *,
    scope: str,
    variation_id: int | None = None,
    corpus_filter: dict[str, Any] | None = None,
    write_research_report: bool = True,
    reason: str | None = None,
) -> dict[str, Any]

Trigger a distillation agent for the experiment (M9 §17).

Parameters:

Name Type Description Default
experiment_id str

Target experiment.

required
scope str

"variation", "experiment", or "corpus".

required
variation_id int | None

Required when scope="variation".

None
corpus_filter dict[str, Any] | None

Required when scope="corpus"; shape per DistillCorpusFilter on the server.

None
write_research_report bool

Whether to also write a research_report alongside the takeaways_report for experiment-scoped distillations. Default True.

True
reason str | None

Free-form audit string.

None

Returns the 202 body: {distillation_job_id, scope, expected_outputs, tartarus_instance_id}. Distillation runs async inside tartarus-d; poll GET /experiments/{id}/agents to watch progress.

fork

fork(
    experiment_id: str,
    *,
    hypothesis_summary: str,
    rationale: str | None = None,
    config_yaml: str | None = None,
    slug: str | None = None,
    allow_retracted_parent: bool = False,
) -> Experiment

Fork an experiment: create a new experiment whose repo mirrors the source's full git history, with a lineage edge to the source. Returns a handle to the new (forked) experiment.

get_agent_config

get_agent_config(experiment_id: str) -> dict[str, Any]

Read the experiment's agent_config JSON block (M11).

Returns {} for experiments that never had a block set. Shape per runes/chronicle/designs/agent-flows.md §13: distillation (default-on auto-trigger + auto-on-conclude knobs) and continuous_exploration (opt-in synthesis wake-up loop).

git_status

git_status(experiment_id: str) -> GitStatus

Current git-integration state for the experiment.

Returns lightweight status info — state (pending/ready/failed/archived), repo_url (when ready), failure_reason (when failed). Cheap to poll; UI calls this every couple seconds while state is pending.

iter

iter(
    *,
    status: str | None = None,
    created_by: str | None = None,
    page_size: int | None = None,
) -> Iterator[ExperimentSummary]

Yield every experiment matching the filters, paging server-side as needed.

list

list(
    *,
    status: str | None = None,
    created_by: str | None = None,
    page_size: int | None = None,
    page_token: str | None = None,
) -> ExperimentListPage

One page of experiments matching the filters.

The server paginates on ?limit + ?before=<created_at>_<id> and returns a bare array (no envelope token); page_size / page_token are the SDK's stable names for those. The next cursor is derived from the last row when the page comes back full — a short page is the end. Use :meth:iter to walk every page.

mint_git_token

mint_git_token(experiment_id: str) -> GitToken

Mint a 1-hour install token scoped to this experiment's repo.

The returned token has Administration permission stripped — pushes to agent/* branches will be rejected by branch protection. Use it to clone the repo and push to user/... branches you create.

Raises ServerError(503) if the server has no GitHub App configured; ConflictError(409) if the experiment's repo isn't ready yet.

move

move(
    experiment_id: str,
    *,
    organization_id: str,
    team_id: str | None = None,
    visibility: str | None = None,
) -> dict[str, Any]

Transfer a personal experiment into an organization.

Personal → org only: the experiment's owner becomes organization_id (and team_id if given), the org's admins gain read + administer, and visibility sets who else can read it — "private" (creator + org admins), "organization" / "team" (org/team members get read + discuss; the default in an org context), or "public" (anyone). The original creator keeps their access; the move only adds org reach (owner_subject is unchanged).

Raises ConflictError (409) if the experiment is already owned by an org, or if its slug collides with an existing experiment in the target org (rename it first via PUT /experiments/{id}). Requires Administer on the experiment and membership of the target org.

promote_lineage

promote_lineage(
    experiment_id: str, parent_id: str
) -> dict[str, Any]

Promote a tentative parent link to real (explicit) lineage.

The deliberate "yes, this fork is real lineage" action: the parent then appears in :meth:get parent_ids and the lineage DAG. Requires the parent to have committed (else 409); a tentative link off a still- open parent is not yet promotable. Idempotent — re-promoting an already-explicit link succeeds.

record_finding

record_finding(
    experiment_id: str,
    *,
    status: str,
    summary: str,
    evidence_variation: int,
    evidence_run: int | None = None,
    source_asset_id: str | None = None,
) -> dict[str, Any]

Record a what's-working / what's-not finding onto the experiment's running summary (issue #357).

The finding is the one-line signal that lands on the experiment page's running-summary header and the activity feed (a finding.recorded event). The server keys the summary on evidence_variation — recording again for the same variation replaces its finding, so refine freely as reports land. Judge status from the metrics, not the run's succeed/fail outcome. Requires Write on the experiment. (MCP-native agents have the same surface as the chronicle.record_finding tool.)

Parameters:

Name Type Description Default
experiment_id str

Target experiment.

required
status str

"working" (improved on baseline / confirmed the hypothesis), "partial" (mixed or conditional), or "not_working" (regressed, or cleanly ruled the approach out).

required
summary str

The signal in one sentence.

required
evidence_variation int

The variation whose outcome is the evidence — the upsert key.

required
evidence_run int | None

Optional specific run within the variation.

None
source_asset_id str | None

Optional report asset (variation_report / takeaways_report) the finding was judged from.

None

Returns the updated running summary ({version, updated_at, findings: [...]}).

set_agent_config

set_agent_config(
    experiment_id: str, config: dict[str, Any]
) -> dict[str, Any]

Replace the experiment's agent_config block.

Validates the continuous_exploration sub-block strictly (400 with kind: "invalid_continuous_exploration" on malformed shape); other sub-blocks pass through. Pass {} to clear all knobs back to defaults.

set_continuous_exploration

set_continuous_exploration(
    experiment_id: str,
    *,
    enabled: bool,
    trigger_scope: str = "variation",
    cooldown_minutes: int = 0,
) -> dict[str, Any]

Convenience wrapper around :meth:set_agent_config for the M11 continuous_exploration block.

Merges the new block into the existing agent_config (read first, then PUT) so other knobs (distillation defaults, steering caps, etc.) are preserved.

Parameters:

Name Type Description Default
experiment_id str

Target experiment.

required
enabled bool

Whether the closed-loop wake-up fires on distillation completion.

required
trigger_scope str

"variation", "experiment", or "both". Default "variation".

'variation'
cooldown_minutes int

0 .. 1440. 0 = no coalescing (publish every completion). Larger = coalesce multiple completions into one wake-up.

0
tentative_links(
    experiment_id: str,
) -> list[TentativeParentLink]

The experiment's tentative parent lineage links.

A tentative link is created when an experiment is created/forked off a parent that is still open (uncommitted). It is excluded from lineage reads (won't show in :meth:get parent_ids or :meth:get_lineage) and blocks the child's commit until resolved. Returns [] for the common case (no tentative links). Resolve each by :meth:promote_lineage (once promotable) or by passing a drop/promote disposition to :meth:commit.

wait_for_repo

wait_for_repo(
    experiment_id: str,
    *,
    timeout: float = 300.0,
    poll_interval: float = 2.0,
) -> GitStatus

Poll git_status until the repo is ready or failed, or timeout.

methodic.VariationsAPI

VariationsAPI(transport: Transport, chronicle: Chronicle)

Variations namespace. Keys every operation on (experiment_id, variation).

commit

commit(
    experiment_id: str,
    variation: int,
    *,
    commit_without_hypothesis: bool = False,
) -> dict[str, Any]

Commit (lock) a variation. Refused (409 missing_variation_hypothesis) when the variation has no recorded hypothesis unless commit_without_hypothesis=True — a deliberate, audited choice to commit without pre-registration.

create

create(
    experiment_id: str,
    *,
    config_yaml: str,
    accelerate_config_yaml: str | None = None,
    launch_config: dict[str, Any] | None = None,
    description: str | None = None,
    input_asset_ids: list[str] | None = None,
    git_ref: str | None = None,
    name: str | None = None,
    hypothesis: str | None = None,
    expected_outcome: str | None = None,
) -> Variation

Create a new variation under experiment_id. Returns a Variation handle.

hypothesis records the falsifiable hypothesis this variation validates (tied to the eval metric) — the variation's pre-registration, the analog of an experiment's hypothesis. expected_outcome is the predicted result vs. baseline. Both can also be set/refined after creation via :meth:update while the variation is open. A variation with no hypothesis can only be committed via the explicit commit_without_hypothesis override.

git_ref optionally associates the variation with a branch on the experiment's GitHub repo. Server captures the branch name now; SHA resolution + the branch-rename-to-agent/... flow happens at variation commit (Phase 3). Pre-Phase-3, registering with git_ref is informational only.

list_inputs

list_inputs(
    experiment_id: str, variation: int
) -> list[dict[str, Any]]

List the variation's input assets (each a dict with id, asset_type, name, …). Use to find a linked code_artifact — e.g. a bundle to clean up after rebinding the variation to git.

list_outputs

list_outputs(
    experiment_id: str, variation: int
) -> list[dict[str, Any]]

List the variation's output assets across all its runs (each a dict with id, asset_type, name, state, created_at, …), newest-first. Use to find produced checkpoints, snapshots, and reports — e.g. the latest ready checkpoint to resume from.

set_git_ref

set_git_ref(
    experiment_id: str, variation: int, git_ref: str
) -> dict[str, Any]

Bind a branch to an open variation — records the variation→branch mapping without pinning a SHA (the SHA locks at commit).

Used by the create-first fork flow: create the variation, name the branch variation/<id>, push it, then bind it here so a tartarus agent (or the commit-time rename) knows which branch belongs to the variation. The server rejects the protected agent/* namespace and returns 409 if the variation is already committed.

unlink_input(
    experiment_id: str, variation: int, asset_id: str
) -> dict[str, Any]

Remove an input-asset link from an open variation.

Unlinks the asset from the variation's inputs without deleting the asset itself — hard-delete the now-orphaned asset separately via chronicle.assets.delete. Refused with 409 once the variation is committed (inputs freeze on commit). Used to drop a stale code_artifact — e.g. a bundle that a later git-ref binding superseded — while the variation is still open.

update

update(
    experiment_id: str,
    variation: int,
    *,
    hypothesis: str | None = None,
    expected_outcome: str | None = None,
    description: str | None = None,
    name: str | None = None,
) -> VariationData

Edit an open variation's mutable metadata. Omitted fields are left untouched; pass "" to clear one. Raises (409) once the variation is committed. Primary use: record or refine the pre-registered hypothesis before commit.

methodic.RunsAPI

RunsAPI(
    transport: Transport,
    assets: AssetsAPI,
    executor: ThreadPoolExecutor,
)

Run-lifecycle namespace. Stateless across calls; takes the run triple as args.

fail

fail(
    experiment_id: str,
    variation: int,
    run: int,
    *,
    reason: str = "crash",
) -> None

Mark a run failed. reason is crash (worker error) or abandoned (cancel).

list_outputs

list_outputs(
    experiment_id: str, variation: int, run: int
) -> list[dict[str, Any]]

List the output assets produced by a single run (newest-first).

list_variation_outputs

list_variation_outputs(
    experiment_id: str, variation: int
) -> list[dict[str, Any]]

List output assets produced across all runs of a variation (newest-first). The resume-discovery scope: a fresh run finds the prior run's checkpoint here.

start

start(
    experiment_id: str,
    variation: int,
    run: int,
    *,
    wandb_run_id: str | None = None,
    wandb_entity: str | None = None,
    wandb_project: str | None = None,
    wandb_dashboard_url: str | None = None,
) -> None

Mark the run started. Optionally link its W&B run by passing the full triple (wandb_run_id + wandb_entity + wandb_project; project falls back server-side to the experiment's wandb_project). The link is the (exp,var,run) → W&B run pointer distillation reads — agent-side with its own key, or via the backend wandb_fetch_* broker.

methodic.AssetsAPI

AssetsAPI(
    transport: Transport,
    *,
    chronicle: Chronicle | None = None,
    default_organization_id: str | None = None,
)

Asset operations.

Output-of linking (which experiment/variation/run produced this asset) is passed explicitly by callers — Run populates it from its bound context, while researcher-level uploads pass it directly or omit it for shared assets.

approve

approve(asset_id: str) -> dict[str, Any]

Clear review_required from an asset's pending_reasons. Auto-finalizes the asset if that was the last reason (state → ready). Idempotent on already-terminal assets. See design.md § Pending reasons. Requires Write permission on the asset.

bulk_approve

bulk_approve(
    asset_ids: list[str] | None = None,
    *,
    all_in_scope: bool = False,
) -> BulkApproveResponse

Approve review-gated reports in a batch — the attention feed's "approve selected" / "approve all" action.

Exactly one mode (a ValueError is raised before any request otherwise):

  • pass asset_ids to approve the listed assets ("approve selected");
  • pass all_in_scope=True to approve every pending review_required asset the caller can act on in scope ("approve all N").

Partial success is normal: the call returns HTTP 200 with a per-asset result list, and a 404 / permission denial / validation failure on one asset surfaces as an "error" row rather than aborting the batch. Each asset's Write permission is re-checked server-side, so an all_in_scope candidate you can read but not write fails its own row.

Returns a :class:~methodic.types.BulkApproveResponse (results + approved / failed counts).

create_inline

create_inline(
    *,
    asset_type: str,
    content: Any,
    name: str | None = None,
    content_type: str = "application/json",
    output_of: dict[str, Any] | None = None,
    asset_config: dict[str, Any] | None = None,
    pending_reasons: list[str] | None = None,
    organization_id: str | None = None,
    team_id: str | None = None,
    visibility: str | None = None,
) -> dict[str, Any]

Upload a small inline asset. Chronicle auto-finalizes unless pending_reasons is non-empty — see design.md § Pending reasons. Valid reasons: upload_in_progress (not on inline content), compile_pending, review_required.

Pass organization_id (and/or team_id) to create the dataset under an org you belong to, and visibility ("private" | "organization" / "team" | "public") to set who else can read it — same model as experiments.create. Omitting organization_id falls back to the client's configured default org (if any); pass methodic.PERSONAL to force a personal, private dataset.

create_with_presigned

create_with_presigned(
    *,
    asset_type: str,
    components: list[str],
    name: str | None = None,
    content_type: str = "application/octet-stream",
    output_of: dict[str, Any] | None = None,
    asset_config: dict[str, Any] | None = None,
    pending_reasons: list[str] | None = None,
    organization_id: str | None = None,
    team_id: str | None = None,
    visibility: str | None = None,
) -> AssetUploadInfo

Register a new asset and get presigned PUT URLs for each component. Accepts an optional asset_config (stored on the asset — datasets put their provenance record here) and a pending_reasons list (see create_inline). organization_id / team_id / visibility set the dataset's org context + visibility (see create_inline); organization_id falls back to the client's configured default org.

delete

delete(asset_id: str) -> dict[str, Any]

Hard-delete an asset that is not linked to any experiment — no experiment/variation input links and no output links reference it. The cleanup path for orphans (over-uploaded datasets, abandoned pending uploads). Removes the row, its ACLs, inline content, storage bytes, and search document. Returns the server's removal summary. Irreversible.

Refused with ConflictError (409, kind: "asset_linked", per-table counts in links) while the asset is linked anywhere — a linked asset is part of an experiment's record; take it out of use with :meth:deprecate/:meth:invalidate instead. Requires the Delete action on the asset.

deprecate

deprecate(asset_id: str, reason: str) -> dict[str, Any]

Soft-warn on an asset: it stays usable as an input, but the deprecation (with reason) is surfaced as a warning wherever it is linked. The right call for "superseded, but existing results stand". Returns the updated asset.

download

download(asset_id: str, local_dir: Path) -> Path

Download all components of an asset to a local directory.

finalize

finalize(asset_id: str) -> None

Mark a presigned-upload asset as ready (immutable) once all components are up.

get

get(
    asset_id: str, *, include_presigned: bool = False
) -> dict[str, Any]

Fetch asset metadata. With include_presigned=True, includes read URLs.

grant_access

grant_access(
    asset_id: str, principal_id: str, action: str = "read"
) -> dict[str, Any]

Grant a principal (a user sub, team id, org id, or everyone) an action on a dataset/asset. action defaults to read; others: write, delete, administer. Idempotent. Requires Administer on the asset (its creator has it).

invalidate

invalidate(asset_id: str, reason: str) -> dict[str, Any]

Hard-block an asset: it can no longer be linked as an input without allow_invalid_assets. The row, links, and provenance survive (unlike :meth:delete) — the right call for "wrong data, do not build on this" when the asset is part of an experiment's record. Returns the updated asset.

list_access

list_access(asset_id: str) -> dict[str, Any]

List the access-control entries on a dataset/asset — who can read/write it. Requires Read on the asset. Returns the server's object-aces payload.

move

move(
    asset_id: str,
    *,
    organization_id: str,
    team_id: str | None = None,
    visibility: str | None = None,
) -> dict[str, Any]

Transfer a dataset/asset into an organization — the asset analog of experiments.move. Sets its owning org/team so it lists under the org (GET /assets?owner=…) and bills to it; visibility ("private" | "organization" / "team" | "public", default org-wide) sets who else can read it. Requires Administer on the asset and membership of the target org (resolve the id via chronicle.me.scopes()). created_by is unchanged.

presign

presign(
    asset_id: str,
    *,
    operation: str = "read",
    components: list[str] | None = None,
) -> dict[str, Any]

Request presigned URLs for an asset's components.

reject

reject(asset_id: str, reason: str) -> dict[str, Any]

Reject a review-gated asset — clears review_required AND transitions pending → abandoned, recording rejected_at and rejection_reason. Requires Write on the asset; reason is a required free-text explanation surfaced in audit + UI.

revoke_access

revoke_access(
    asset_id: str, principal_id: str, action: str
) -> dict[str, Any]

Revoke a principal's (principal, action) grant on a dataset. Idempotent — the response removed is False if the grant didn't exist. Requires Administer on the asset.

set_visibility

set_visibility(
    asset_id: str, visibility: str
) -> dict[str, Any]

Set an asset's visibility — its broadcast read grant — independent of any experiment it's linked to. visibility is "private" | "org" (a.k.a. "organization" / "team") | "public".

Use this to share a single report/dataset more widely — e.g. make one report "public" — without exposing the whole experiment. "private" removes the broadcast grant; per-person shares from :meth:grant_access and experiment-inherited access are left untouched (visibility controls only the single broadcast grant). Unlike :meth:move, this does not change the asset's owning org. Requires Administer on the asset (its creator has it; an experiment's admins have it on the experiment's reports). Returns the asset.

share_with_scope

share_with_scope(
    asset_id: str, scope_id: str, action: str = "read"
) -> dict[str, Any]

Share a dataset with a whole team or organization: grant the scope (a team/org id — see chronicle.me.scopes()) read (default) on the asset so its members reach it. A thin convenience over :meth:grant_access; requires Administer on the asset and that you belong to the scope.

upload_component

upload_component(
    upload_url: str, local_path: Path, content_type: str
) -> None

PUT one component to its presigned URL.

methodic.SearchAPI

SearchAPI(transport: Transport, chronicle: Chronicle)

Vertex-backed search across research docs, experiment metadata, and arxiv assets.

history

history(
    query: str,
    *,
    experiment_context: list[str] | None = None,
    created_by: str | None = None,
    created_after: str | None = None,
    created_before: str | None = None,
    asset_types: list[str] | None = None,
    page_size: int | None = None,
    page_token: str | None = None,
) -> SearchResponse

Search Chronicle's INTERNAL corpus — your experiment history and internal research documents (hypothesis/takeaways/research reports, experiment metadata).

This is a semantic alias for :meth:query with the common narrowing knobs (author, time window, asset type) lifted into keyword args and assembled into a SearchFilters. It hits the same POST /search endpoint and is RBAC- and storage-prefix-scoped server-side.

NOTE: this does NOT search external literature. arxiv / paper search is served by a SEPARATE external MCP, not by this method or by Chronicle's search API.

asset_types defaults to None — Chronicle already excludes session assets server-side, so the unfiltered corpus is the right default. Pass a list to narrow to specific types (e.g. ["research_report"]).

iter

iter(
    query: str,
    *,
    filters: SearchFilters | dict[str, Any] | None = None,
    experiment_context: list[str] | None = None,
    scope: SearchScope | dict[str, Any] | None = None,
    page_size: int | None = None,
) -> Iterator[SearchResult]

Yield every search hit, paging server-side as needed.

query

query(
    query: str,
    *,
    filters: SearchFilters | dict[str, Any] | None = None,
    experiment_context: list[str] | None = None,
    scope: SearchScope | dict[str, Any] | None = None,
    page_size: int | None = None,
    page_token: str | None = None,
) -> SearchResponse

Run a single search request. Returns one page; use iter to walk pages.

scope is a HARD filter that restricts results to the union of the named collections + experiments (and member experiments' outputs), distinct from experiment_context which only boosts. Pass a :class:~methodic.SearchScope or a raw {collections, experiments} dict; an empty scope paired with experiment_context hard-scopes to that context. See collections.md §"Search integration".

methodic.DatasetsAPI

DatasetsAPI(transport: Transport, assets: AssetsAPI)

Dataset operations. Reuses AssetsAPI for the asset mechanics and the transport for the input-link endpoints.

get

get(
    asset_id: str, *, include_presigned: bool = False
) -> dict[str, Any]

Fetch the dataset asset's metadata.

link(
    asset_id: str,
    experiment_id: str,
    *,
    variation: int | None = None,
    propagate_acl: bool = True,
    allow_invalid_assets: bool = False,
) -> dict[str, Any]

Link a dataset as an experiment- or variation-level input.

Experiment-level (variation=None) stamps the experiment's auto-roles onto the asset when propagate_acl (the default) so experiment members can read it; pass propagate_acl=False for a sensitive dataset. Variation-level linking ignores propagate_acl — the server does not propagate ACLs to variation inputs. Either target must be open (the server returns 409 once committed).

list

list(
    *,
    n_dims: int | None = None,
    min_dims: int | None = None,
    max_dims: int | None = None,
    precision: str | None = None,
    pde_family: str | None = None,
    geometry: str | None = None,
    min_size: int | None = None,
    max_size: int | None = None,
    order_by: str | None = None,
    owner: str | None = None,
) -> list[dict[str, Any]]

List readable dataset assets with promoted-column filters (n_dims/min_dims/max_dims, precision, pde_family, geometry, min_size/max_size) and order_by ∈ {size_bytes, created_at, num_samples}. Cheap Postgres-side filtering; the Vertex search path (chronicle.search) is the primary paginated + semantic discovery surface. See datasets.md.

load

load(asset_id: str, dest: str | Path) -> Path

Download all of a dataset's components into dest (created if needed). Returns the destination directory.

provenance

provenance(asset_id: str) -> dict[str, Any] | None

Return the dataset's stored provenance record, or None if absent.

register

register(
    components: list[str],
    *,
    name: str | None = None,
    asset_type: str = "dataset",
    content_type: str = "application/octet-stream",
    provenance: dict[str, Any] | None = None,
    output_of: dict[str, Any] | None = None,
    organization_id: str | None = None,
    team_id: str | None = None,
    visibility: str | None = None,
    metadata: dict[str, Any] | None = None,
)

Create the dataset asset record + presigned PUT URLs without uploading bytes — for when you drive the component PUTs yourself (custom transfer, resumable retries, externally generated data). Returns the AssetUploadInfo (asset_id, asset_uri, upload_urls); the caller uploads each component (chronicle.assets.upload_component) and then finalizes (chronicle.assets.finalize). provenance, if given, is stored verbatim under asset_config.provenance; metadata is the dataset metadata document stored under asset_config.metadata. Org scope + visibility behave as in :meth:upload.

register_by_reference

register_by_reference(
    uri: str,
    *,
    name: str,
    metadata: dict[str, Any] | None = None,
    size_bytes: int | None = None,
    content_type: str = "application/octet-stream",
    organization_id: str | None = None,
    team_id: str | None = None,
    visibility: str | None = None,
) -> dict[str, Any]

Register a dataset whose bytes already live at uri (gs:// or s3://), uploaded out-of-band — created ready in one call with no upload step (mirrors external reference assets). metadata is the dataset metadata document (stored under asset_config.metadata and projected to the promoted columns + Vertex search facets); size_bytes is author-declared. Returns the created asset. See datasets.md.

update_metadata

update_metadata(
    asset_id: str,
    *,
    metadata: dict[str, Any] | None = None,
    size_bytes: int | None = None,
) -> dict[str, Any]

Update a dataset's mutable descriptive metadata annotation (asset_config.metadata) and/or size_bytes. The immutable content (uri / sha256 / components) is untouched; the promoted columns + Vertex projection are recomputed. Requires Write on the asset. See datasets.md.

upload

upload(
    path: str | Path,
    *,
    name: str | None = None,
    asset_type: str = "dataset",
    content_type: str = "application/octet-stream",
    source: str | None = None,
    provenance: dict[str, Any] | None = None,
    output_of: dict[str, Any] | None = None,
    link_experiment: str | None = None,
    link_variation: int | None = None,
    propagate_acl: bool = True,
    allow_invalid_assets: bool = False,
    organization_id: str | None = None,
    team_id: str | None = None,
    visibility: str | None = None,
    metadata: dict[str, Any] | None = None,
) -> DatasetRef

Upload a dataset file or directory and finalize it.

path is a single file (one component) or a directory (one component per file — the way to shard GB-scale data). A provenance record is computed over the bytes and stored on the asset's asset_config.

Pass link_experiment (and optionally link_variation) to link the finalized dataset as an input in the same call. output_of instead records the (experiment, variation, run) that produced this dataset — note that an output_of-linked non-report asset requires Write on that experiment server-side.

organization_id / team_id / visibility set the dataset's owning scope + visibility (same model as experiments.create). When organization_id is omitted and the upload is researcher-initiated (no output_of), the client's default organization applies; methodic.PERSONAL forces a personal-scope upload.

methodic.CollectionsAPI

CollectionsAPI(transport: Transport, chronicle: Chronicle)

Named, ACL'd, scope-owned groupings of assets + experiments.

Wraps the REST collection endpoints. Authorization, scope resolution, and the existence-only member filtering are applied server-side: callers don't filter for them.

add

add(
    collection_id: str,
    *,
    asset_ids: list[str] | None = None,
    experiment_ids: list[str] | None = None,
    reindex_mode: str = "lazy",
) -> dict[str, Any]

Add asset and/or experiment members. POST /v1/collections/{id}/members.

Requires Write on the collection and Read on each member; a member the caller can't read (or that doesn't exist) is skipped with a warning rather than failing the batch. Idempotent per member. Returns {added, warnings}.

reindex_mode ("lazy" default / "eager") controls only search freshness — the Postgres membership is committed either way. Pass "eager" for a curate-then-search-now flow.

associate

associate(
    experiment_id: str, collection_ids: list[str]
) -> dict[str, Any]

Associate collections with an experiment (boost/scope anchor). POST /v1/experiments/{id}/collections.

Requires Write on the experiment and Read on each collection; an unreadable/missing collection is skipped with a warning. Never indexed (query-time resolution). Returns {associated, warnings}. The agent flow can associate a collection at experiment setup so later searches with experiment_context boost — and scope restricts — to it.

create

create(
    name: str,
    *,
    description: str | None = None,
    scope_id: str | None = None,
) -> dict[str, Any]

Create a collection. POST /v1/collections.

scope_id is the owning principal (principals.id: a user, team, or org); omitted, it defaults server-side to the caller's active scope (a personal collection in a personal context). Returns the created Collection.

get

get(collection_id: str) -> dict[str, Any]

Fetch one collection (with member counts). GET /v1/collections/{id}.

The hydrated member_asset_count / member_experiment_count reflect the caller's visible members (existence-filtered), not the true totals.

list

list(
    *,
    scope_id: str | None = None,
    q: str | None = None,
    include_archived: bool = False,
) -> list[dict[str, Any]]

List collections the caller can Read. GET /v1/collections.

scope_id narrows to one owning scope (defaults to the caller's personal scope server-side); q is a case-insensitive substring filter on the name; include_archived includes soft-archived collections.

members

members(collection_id: str) -> dict[str, Any]

List the members the caller can independently Read. GET /v1/collections/{id}/members.

Existence-filtered: a member the caller can't Read is omitted entirely (no membership oracle). Returns {assets, experiments}, each a list of membership rows.

remove

remove(
    collection_id: str,
    *,
    asset_ids: list[str] | None = None,
    experiment_ids: list[str] | None = None,
    reindex_mode: str = "lazy",
) -> dict[str, Any]

Remove asset and/or experiment members. DELETE /v1/collections/{id}/members.

Requires Write on the collection (no per-member Read — you can prune anything in your own collection). Returns {removed}. reindex_mode governs search freshness only.

methodic.TagsAPI

TagsAPI(transport: Transport, chronicle: Chronicle)

Tag operations. Stateless; ids are passed explicitly.

asset_tags

asset_tags(asset_id: str) -> list[dict[str, Any]]

Tags applied to an asset. GET /v1/assets/{id}/tags.

create

create(
    name: str, *, scope_id: str | None = None
) -> dict[str, Any]

Find-or-create a tag in a scope. POST /v1/tags.

Idempotent: an existing (scope, normalized-name) returns its canonical row. scope_id defaults to your personal scope; a team/org scope requires act-as access. Returns the tag record ({id, scope_id, name, normalized, created_by, created_at}).

delete

delete(tag_id: str) -> dict[str, Any]

Delete a tag (cascades its taggings). DELETE /v1/tags/{id}.

experiment_tags

experiment_tags(experiment_id: str) -> list[dict[str, Any]]

Tags applied to an experiment. GET /v1/experiments/{id}/tags.

get

get(tag_id: str) -> dict[str, Any]

Fetch one tag by id. GET /v1/tags/{id}.

list

list(
    *, scope_id: str | None = None, q: str | None = None
) -> list[dict[str, Any]]

List/autocomplete a scope's tags. GET /v1/tags.

q is a prefix matched against the normalized slug; each row carries a usage_count. scope_id defaults to your personal scope.

objects

objects(tag_id: str) -> dict[str, Any]

The objects a tag is applied to. GET /v1/tags/{id}/objects.

Returns {assets: [...], experiments: [...]}.

rename

rename(tag_id: str, name: str) -> dict[str, Any]

Rename a tag in one place. PATCH /v1/tags/{id}.

The new name re-stamps every asset/experiment carrying the tag in the search index (rare). Requires act-as access on the tag's scope.

tag_asset

tag_asset(
    asset_id: str,
    *,
    tag: str | None = None,
    tag_id: str | None = None,
) -> dict[str, Any]

Apply a tag to an asset. POST /v1/assets/{id}/tags.

Pass tag (a name — find-or-created in the asset's scope) or tag_id (an existing tag). Requires Write on the asset.

tag_experiment

tag_experiment(
    experiment_id: str,
    *,
    tag: str | None = None,
    tag_id: str | None = None,
) -> dict[str, Any]

Apply a tag to an experiment. POST /v1/experiments/{id}/tags.

Pass tag (a name) or tag_id. Requires Write on the experiment.

untag_asset

untag_asset(asset_id: str, tag_id: str) -> dict[str, Any]

Remove a tag from an asset. DELETE /v1/assets/{id}/tags.

untag_experiment

untag_experiment(
    experiment_id: str, tag_id: str
) -> dict[str, Any]

Remove a tag from an experiment. DELETE /v1/experiments/{id}/tags.

methodic.ReportsAPI

ReportsAPI(transport: Transport, chronicle: Chronicle)

Reports namespace. Stateless; every method takes the experiment id + report kind explicitly. Most users access via exp.reports instead of calling these methods directly.

get

get(asset_id: str) -> Report

Fetch a report by its asset id. Returns the asset metadata plus content (compile_status, log, pdf_b64 if compiled).

render

render(
    experiment_id: str,
    kind: str,
    *,
    payload: dict[str, Any] | None = None,
    tex_body: str | None = None,
    template_asset_id: str | None = None,
) -> Report

Render (and compile, when chronicle-tex is configured) a report.

Pass payload for template mode; tex_body for freeform mode. Mode is selected by the experiment's report_settings.{kind}.mode — these args feed the chosen mode and are ignored otherwise.

update_settings

update_settings(
    experiment_id: str, *, settings: dict[str, Any]
) -> dict[str, Any]

Replace experiment.report_settings with settings. Frozen at commit — server returns 409 once the experiment is committed.

methodic.ResearchPromptsAPI

ResearchPromptsAPI(
    transport: Transport, chronicle: Chronicle
)

Research prompts namespace. Stateless; every method takes ids explicitly.

Most users access the per-experiment view via exp.research_prompts instead of calling these methods directly.

attach

attach(
    experiment_id: str,
    research_prompt_id: str,
    *,
    primary: bool = False,
) -> dict[str, Any]

Associate an existing research prompt with an experiment.

Set primary=True to make it the experiment's primary prompt. Requires Write on the experiment. Returns {experiment_id, research_prompt_id, is_primary}.

create

create(
    prompt: str, *, experiment_ids: list[str] | None = None
) -> dict[str, Any]

Create a research prompt and return the created record.

The server stores the prompt and reads it back, so the response is the full record: {id, prompt, created_at, created_by} (HTTP 201). Prompts are immutable once created.

The create endpoint takes only the prompt text — it does not link to experiments. Pass experiment_ids to attach the new prompt to those experiments in follow-up calls (none of them primary; call :meth:attach with primary=True if you need a primary link).

get

get(research_prompt_id: str) -> dict[str, Any]

Fetch a research prompt by id. Returns {id, prompt, created_at, created_by}; raises NotFoundError if it doesn't exist.

list_for_experiment

list_for_experiment(
    experiment_id: str,
) -> list[dict[str, Any]]

List the research prompts associated with an experiment.

Returns a list of {research_prompt: {...}, is_primary: bool} records (at most one has is_primary: true). Requires Read on the experiment.

methodic.imports.ImportsAPI

ImportsAPI(
    transport: Transport,
    assets: AssetsAPI,
    *,
    default_organization_id: str | None = None,
)

Org-library document imports. Organization scope is mandatory — these are never personal uploads (the server refuses them).

research_reports

research_reports(
    paths: list[Path | str] | Path | str,
    *,
    organization_id: str | None = None,
    team_id: str | None = None,
    visibility: str | None = None,
    import_source: str | None = None,
    collection: str | None = None,
    collections: list[str] | str | None = None,
    finalize: bool = True,
) -> ImportSummary

Import one or more research-report PDFs into organization_id (or the client default org). Directories expand to their *.pdf files (non-recursive); explicitly-named non-PDF files raise.

Per file: register (imported_report, sha256/size provenance) → presigned PUT → collect. Then one /assets/bulk-finalize over the batch verifies the uploads, flips them ready, and enqueues the extraction job. Duplicates within the org report as duplicates — re-running a batch is safe.

collection / collections (fluent — pass either or both, each an id or a slug) target one or more collections: every report that reaches ready is added as a member at finalize (the server resolves slugs within the org scope and requires Write on each collection — an unresolvable slug or missing grant fails the finalize call). A collection a report could not be added to (e.g. Write lost mid-batch) is reported per-asset in collection_warnings, never failing the batch.

methodic.errata.ErrataAPI

ErrataAPI(transport: Transport, chronicle: 'Chronicle')

Create and list errata. Paths are version-free; Transport prepends /v1.

create

create(
    *,
    experiment_id: str | None = None,
    variation: int | None = None,
    asset_id: str | None = None,
    markdown: str | None = None,
    latex: str | None = None,
    pdf: str | Path | bytes | None = None,
    name: str | None = None,
) -> dict[str, Any]

Create an erratum and attach it to its target in one call.

Exactly one of markdown/latex (authored) or pdf (imported) must be given. Returns the hydrated erratum ({asset, target, created_by, created_at}). Requires Write on the target.

delete

delete(errata_asset_id: str) -> None

Detach an erratum from its target. Requires Write on the target.

list

list(
    *,
    experiment_id: str | None = None,
    variation: int | None = None,
    asset_id: str | None = None,
) -> list[dict[str, Any]]

List the errata correcting a given object (chronological).

methodic.PublicationsAPI

PublicationsAPI(transport: Transport, chronicle: Chronicle)

Publication operations. Stateless; ids are passed explicitly.

finalize

finalize(
    publication_id: str,
    *,
    doi: str | None = None,
    bibtex: str | None = None,
) -> dict[str, Any]

Finalize a draft publication. POST /v1/publications/{id}/finalize.

Promotes the draft in place to a registered, public, system-owned record (resolving doi/bibtex). The asset id is unchanged, so existing citation links stay intact. Only a draft you can Write may be finalized; registered publications are immutable. Returns {"publication": {...}}.

get

get(publication_id: str) -> dict[str, Any]

Fetch a publication record (an asset). GET /v1/assets/{id}.

register

register(
    *,
    doi: str | None = None,
    bibtex: str | None = None,
    draft: bool = False,
    confirm_create: bool = False,
) -> dict[str, Any]

Register (or dedup to) a publication. POST /v1/publications/register.

Pass doi (resolved via Crossref, falling back to doi.org) or bibtex. A known DOI dedups to the existing record. Returns {"publication": {...}, "existing": bool}; for a BibTeX entry with no DOI that matches existing records, returns {"status": "needs_resolution", "parsed": {...}, "candidates": [...]} — pick one (link it as a citation) or re-call with confirm_create=True to mint a new record.

draft=True registers a private, mutable placeholder for unpublished work (owned by you); :meth:finalize it later.

search

search(q: str | None = None) -> list[dict[str, Any]]

Search registered publications by title. GET /v1/publications.

Returns the list of matching publication assets (newest first). Useful before registering, to reuse an existing record. (Full-text/semantic search rides chronicle.search with asset_types=["publication"].)

methodic.PendingAPI

PendingAPI(transport: Transport, chronicle: Chronicle)

The caller's attention queue (blocked + report_approval).

iter

iter(
    *, limit: int | None = None, scope: str | None = None
) -> Iterator[PendingItem]

Yield every pending item, paging server-side as needed.

list

list(
    *,
    limit: int | None = None,
    before: str | None = None,
    scope: str | None = None,
) -> PendingPage

One page of pending items, newest-first.

The server paginates on ?limit + ?before=<since>_<id> and returns a bare array; next_page_token on the result is built from the last row when the X-Has-More header says more remain. Pass it back as before for the next page, or use :meth:iter to walk them all.

scope is reserved for forward compatibility. The v1 endpoint derives the scope from the calling key/user's readable set (there is no scope/owner query parameter yet), so a value is accepted but not sent; narrow by issuing the call with a scope-restricted key instead.

methodic.ActivityAPI

ActivityAPI(transport: Transport, chronicle: Chronicle)

The caller's reverse-chronological event stream.

iter

iter(*, limit: int | None = None) -> Iterator[ActivityItem]

Yield every activity event, paging server-side as needed.

list

list(
    *, limit: int | None = None, before: str | None = None
) -> ActivityPage

One page of activity events, newest-first.

The server paginates on ?limit + ?before=<timestamp>_<id> and returns a bare array; next_page_token on the result is built from the last row when the X-Has-More header says more remain. Pass it back as before, or use :meth:iter to walk every page.

methodic.ApiKeysAPI

ApiKeysAPI(transport: Transport)

chronicle.api_keys — API key lifecycle.

Keys are owner-scoped: every operation acts on the calling principal's own keys. The restriction on a key narrows it below the owner's authority (never above); a restricted calling key can only mint/edit keys within its own restriction (the server enforces the subset rule).

create

create(
    name: str,
    *,
    key_type: str = "agent",
    expires_at: str | None = None,
    restriction: dict[str, Any] | None = None,
) -> dict[str, Any]

Mint a new API key. Returns the full record including the secret (key) — shown exactly once; store it now.

key_type is "agent" (automation) or "user". restriction is the authority ceiling (see :func:org_ceiling_restriction); omit for full owner authority.

create_org_bound

create_org_bound(
    name: str,
    organization_id: str,
    *,
    key_type: str = "agent",
    expires_at: str | None = None,
) -> dict[str, Any]

Mint a key restricted to mutating only inside organization_id (read stays broad) — the org-context shape. Convenience over :meth:create with :func:org_ceiling_restriction.

list

list() -> list[dict[str, Any]]

Every API key you own — each {id, name, key_type, created_at, expires_at, restriction?}. The id is the handle for :meth:revoke, :meth:rotate, :meth:set_restriction.

revoke

revoke(key_id: str) -> dict[str, Any]

Revoke a key by its id. Immediate — any agent using it is locked out at once.

rotate

rotate(key_id: str) -> dict[str, Any]

Single-step rotation: same key row (id, name, restriction), fresh secret. The old secret stops validating immediately; the new key is returned once. Use when a key leaks or its setup snippet is lost — its identity and grants are unchanged.

set_restriction

set_restriction(
    key_id: str, restriction: dict[str, Any] | None
) -> dict[str, Any]

Replace a key's restriction (Cloudflare-style permission editing). None clears it (full owner authority) — only allowed when the calling credential is itself unrestricted.

methodic.FeedbackAPI

FeedbackAPI(transport: Transport)

Feedback + error-report namespace. Stateless and submit-only.

report_error

report_error(
    error_type: str,
    message: str,
    stack: str | None = None,
    request_method: str | None = None,
    request_path: str | None = None,
    response_status: int | None = None,
) -> None

Report a reproducible error to the bug pipeline. Returns None.

Posts to /v1/errors with source: "sdk" and captured_at stamped now (UTC, ISO 8601). The endpoint is fire-and-forget: the server replies 202 Accepted (with the stored report's id and its own fingerprint), which this method discards. HTTP failures still raise the usual APIError subclasses.

A client-side fingerprint is included for stable cross-report identity: the SHA-256 hex of error_type | message | top stack frames, where the top 5 non-empty stack lines are normalized by stripping line/column numbers, hex addresses, and {{closure}}#N ids (design §4.4 — volatile details shouldn't fork the identity; the same failure reported twice yields the same fingerprint). The server recomputes its own fingerprint from the sanitized fields and treats the client value as advisory.

request_method / request_path / response_status carry the HTTP context when the error came from an API call. Prefer a route template for request_path (/experiments/{id}/git, not a literal path with ids) — the server only strips obvious UUID/integer segments as a backstop.

submit

submit(
    body_md: str,
    category: str = "feedback",
    context: dict[str, Any] | None = None,
    source: str = "sdk",
) -> str

Submit plain (non-bug) feedback. Returns the created feedback id.

body_md is Markdown by contract — tables, fenced code blocks, and lists are all fine; operators read it rendered (sanitized GFM) in the admin bridge. Size-capped server-side at 64 KiB.

category is one of "feedback" (impression/UX), "gap" ("the surface can't express what I needed"), or "feature_request" (explicit ask). Anything else raises ValueError before any request is made.

context is identifiers only — e.g. {"experiment_id": ..., "skill": ..., "sdk_method": ...}. Never put bodies, transcripts, or secrets in it; it exists to make the feedback findable, not to carry payload.

source records which client surface filed the report ("sdk" by default; skills pass "skill").

Reproducible errors are not feedback: if you have a failing call with a message/stack, use :meth:report_error so it gets fingerprinted and triaged by the bug pipeline.

methodic.me.MeAPI

MeAPI(transport: Transport)

/v1/me/* — facts about the calling key's own principal.

Currently just scope discovery; the natural home for /v1/me profile and default-org reads as they're needed by skills.

scopes

scopes() -> list[Scope]

Every scope you can operate as: your personal space plus every team and organization you belong to (directly or transitively). Each carries id, kind ("user" | "team" | "organization"), name, and slug.

Use it to resolve an org/team the user named to the id that :meth:ExperimentsAPI.move and experiments.create(organization_id=…) take, rather than hard-coding a UUID::

org = next(
    s for s in chronicle.me.scopes()
    if s.kind == "organization" and s.slug == "acme"
)
chronicle.experiments.move(exp_id, organization_id=org.id)

Response & data types

methodic.ExperimentData dataclass

ExperimentData(
    id: str,
    owner_subject: str,
    hypothesis_summary: str,
    created_at: str,
    created_by: str,
    state: str,
    rationale: str | None = None,
    description: str | None = None,
    committed_at: str | None = None,
    concluded_at: str | None = None,
    retracted_at: str | None = None,
    retraction_reason: str | None = None,
    git_repo_state: str = "pending",
    git_repo_url: str | None = None,
    git_repo_failure_reason: str | None = None,
)

Mirror of the server's Experiment struct.

git_repo_state defaults to "pending" so older server payloads (which may not include the field yet) deserialize cleanly.

methodic.ExperimentDetail dataclass

ExperimentDetail(
    experiment: Experiment,
    parent_ids: list[str],
    variations: list[VariationSummary],
)

GET /experiments/{id} response: experiment + parents + variation summaries.

methodic.ExperimentSummary dataclass

ExperimentSummary(
    id: str,
    hypothesis_summary: str,
    variation_count: int,
    created_at: str,
    created_by: str,
    state: str,
    status: str | None = None,
    committed_at: str | None = None,
    concluded_at: str | None = None,
    retracted_at: str | None = None,
)

One row in the experiments list.

methodic.ExperimentListPage dataclass

ExperimentListPage(
    results: list[ExperimentSummary],
    next_page_token: str | None = None,
)

One page of experiments.list results plus a cursor for the next page.

The current server returns a flat array; we normalize that into a single-page response with next_page_token=None. When the server grows pagination, the same dataclass keeps working.

methodic.VariationData dataclass

VariationData(
    experiment_id: str,
    variation: int,
    config_json: dict[str, Any],
    config_yaml: str,
    created_at: str,
    created_by: str,
    state: str,
    accelerate_config_json: dict[str, Any] | None = None,
    accelerate_config_yaml: str | None = None,
    launch_config: dict[str, Any] | None = None,
    description: str | None = None,
    committed_at: str | None = None,
    retracted_at: str | None = None,
    retraction_reason: str | None = None,
    git_ref: str | None = None,
    git_sha: str | None = None,
    name: str | None = None,
    hypothesis: str | None = None,
    expected_outcome: str | None = None,
)

Mirror of the server's Variation struct.

config_json, accelerate_config_json, and launch_config arrive as arbitrary JSON — kept as dict[str, Any] since the schema is open.

methodic.VariationSummary dataclass

VariationSummary(
    variation: int,
    created_at: str,
    run_count: int,
    state: str,
    description: str | None = None,
    latest_status: str | None = None,
    committed_at: str | None = None,
    retracted_at: str | None = None,
    name: str | None = None,
    hypothesis: str | None = None,
)

One variation as it appears in ExperimentDetail.variations.

methodic.CreateExperimentResponse dataclass

CreateExperimentResponse(
    experiment_id: str,
    variation: int,
    run: int,
    tentative_parents: list[TentativeParentLink] = list(),
)

POST /experiments response: the new experiment plus the always-created variation 0 / run 0.

tentative_parents is non-empty only when the experiment was created with a parent that was still open — see :class:TentativeParentLink. Empty for the common (no-parent / committed-parent) case.

methodic.LineageResponse dataclass

LineageResponse(
    experiment_id: str,
    ancestors: list[Experiment],
    descendants: list[Experiment],
)

methodic.UpstreamRetraction dataclass

UpstreamRetraction(
    experiment_id: str,
    retracted_at: str,
    reason: str,
    depth: int,
    variation: int | None = None,
    document_asset_id: str | None = None,
    chain: list[str] | None = None,
)

methodic.UpstreamRetractionsResponse dataclass

UpstreamRetractionsResponse(
    has_retractions: bool,
    retractions: list[UpstreamRetraction],
)

methodic.SearchResult dataclass

SearchResult(
    document_id: str,
    source_type: str,
    relevance_score: float,
    lineage_boost: bool,
    asset_type: str | None = None,
    title: str | None = None,
    snippet: str | None = None,
    experiment_ids: list[str] = list(),
    created_at: str | None = None,
)

One hit from the Vertex-backed search.

methodic.SearchResponse dataclass

SearchResponse(
    results: list[SearchResult],
    total_size: int,
    next_page_token: str | None = None,
)

methodic.SearchFilters dataclass

SearchFilters(
    asset_types: list[str] | None = None,
    organization_id: str | None = None,
    team_id: str | None = None,
    created_after: str | None = None,
    created_before: str | None = None,
    created_by: str | None = None,
    source_type: str | None = None,
    tags: list[str] | None = None,
)

Filters layered on top of the RBAC + namespace filters the server adds.

methodic.SearchScope dataclass

SearchScope(
    collections: list[str] = list(),
    experiments: list[str] = list(),
)

A hard scope filter for search — distinct from experiment_context, which only boosts.

When set, results are restricted to the resolved union of the named collections and experiments, ANDed with the server's RBAC allowed_readers filter. Maps to chronicle_core::types::SearchScope and rides POST /v1/search as a top-level scope (a sibling of filters).

"Scope to my current experiment" convenience: pass an empty SearchScope() together with experiment_context to flip those anchors from boost to hard-scope (the server falls back to experiment_context when scope is present but both vectors are empty). See runes/chronicle/designs/collections.md §"Search integration".

methodic.AssetUploadInfo dataclass

AssetUploadInfo(
    asset_id: str,
    asset_uri: str,
    upload_urls: dict[str, str],
)

Result of AssetsAPI.create_with_presigned: where to put each component.

methodic.DatasetRef dataclass

DatasetRef(
    asset_id: str,
    asset_uri: str,
    name: str,
    asset_type: str,
    components: list[str],
    provenance: dict[str, Any],
)

Result of DatasetsAPI.upload: the finalized dataset asset + its provenance.

methodic.GitStatus dataclass

GitStatus(
    state: str,
    default_branch: str = "main",
    repo_url: str | None = None,
    failure_reason: str | None = None,
    branches: list[GitBranch] = list(),
)

Response from GET /experiments/{id}/git.

state mirrors Experiment.git_repo_state. When state == "ready", repo_url is populated; when state == "failed", failure_reason is populated. branches is empty until branch enumeration is wired up server-side (Phase 2 follow-up).

methodic.GitToken dataclass

GitToken(token: str, expires_at: str, repo_url: str)

1-hour GitHub installation access token returned by experiments.mint_git_token.

token is what callers paste into git as https://x:<token>@github.com/<org>/<repo>. Per the design, the token has Administration permission stripped server-side, so it cannot push to agent/* branches or modify branch protection — only the Chronicle App can do those.

methodic.GitBranch dataclass

GitBranch(
    name: str, head_sha: str, variation: int | None = None
)

One branch on the experiment repo, returned by experiments.git_status.

methodic.PendingItem dataclass

PendingItem(
    kind: str,
    id: int,
    since: str,
    variation: int = 0,
    experiment_id: str | None = None,
    experiment_slug: str | None = None,
    experiment_title: str | None = None,
    owner_principal_id: str | None = None,
    agent_id: str | None = None,
    steering_asset_id: str | None = None,
    asset_id: str | None = None,
    asset_type: str | None = None,
    asset_title: str | None = None,
)

One actionable item from GET /v1/pending — something awaiting the caller's action across every object they can read.

kind is "blocked" (a variation whose agent paused for user/coordinator input — act via the experiment's steering_asset_id) or "report_approval" (a review-gated report awaiting approve/reject — act on asset_id via :meth:AssetsAPI.approve / :meth:AssetsAPI.reject or :meth:AssetsAPI.bulk_approve).

id is a stable per-row display id (queue seq for blocked, a derived non-zero int for report_approval) — not the approve/reject identity (use asset_id) and not a standalone cursor (the endpoint paginates on since). variation is the variation index for blocked and 0 for report_approval. The kind-specific fields (agent_id / steering_asset_id for blocked; asset_id / asset_type / asset_title for report_approval) are absent on the other kind. since is the cursor timestamp (suspend transition for blocked, the asset's created_at for report_approval).

methodic.PendingPage dataclass

PendingPage(
    results: list[PendingItem],
    next_page_token: str | None = None,
)

One page of pending.list results plus a cursor for the next page.

The server returns a bare array and signals more via the X-Has-More response header (it over-fetches one row to decide). The SDK reads that header and, when more remain, builds next_page_token from the last item's (since, id) as <since>_<id> — the ?before cursor the server consumes (it keys on the since half). next_page_token is None on the last page. Use :meth:PendingAPI.iter to walk every page.

methodic.ActivityItem dataclass

ActivityItem(
    id: int,
    timestamp: str,
    action: str,
    resource_type: str,
    resource_id: str,
    actor_subject: str,
    via: str | None = None,
    status: str | None = None,
    experiment_id: str | None = None,
)

One event from GET /v1/activity — the caller's reverse-chronological feed of audited actions across the experiments they can read.

action is the audited action (e.g. run.start, asset.create, experiment.conclude); resource_type / resource_id identify the object it touched; actor_subject is who did it. via (optional) records how the action was issued (e.g. a key/instance attribution), status is the outcome where applicable, and experiment_id is the related experiment when one applies. timestamp is the cursor key.

methodic.ActivityPage dataclass

ActivityPage(
    results: list[ActivityItem],
    next_page_token: str | None = None,
)

One page of activity.list results plus a cursor for the next page.

Same wire shape as pending: a bare array + an X-Has-More header. The next ?before cursor is <timestamp>_<id> built from the last item. next_page_token is None on the last page. Use :meth:ActivityAPI.iter to walk every page.

methodic.BulkApproveResponse dataclass

BulkApproveResponse(
    results: list[BulkApproveResult],
    approved: int,
    failed: int,
)

Response from assets.bulk_approve.

Partial success is normal — the call is always HTTP 200 (only auth/parse failures are non-200), and a per-asset failure surfaces as an "error" row rather than aborting the batch. approved / failed are the per-status counts over results.

methodic.BulkApproveResult dataclass

BulkApproveResult(
    asset_id: str,
    status: str,
    error: str | None = None,
    finalized: bool | None = None,
)

Per-asset outcome within a BulkApproveResponse.

status is "approved" or "error". On error, error carries a human-readable reason and finalized is None. On success, finalized reports whether the approval auto-transitioned the asset pending → ready (True) or it still carries other pending reasons (False).

methodic.imports.ImportSummary dataclass

ImportSummary(
    organization_id: str,
    imported: list[ImportedReport] = list(),
    duplicates: list[str] = list(),
    failed: list[tuple[str, str]] = list(),
    extraction_job_id: str | None = None,
    collection_warnings: list[dict[str, Any]] = list(),
)

Outcome of an import batch — counts always add up to the inputs.

methodic.imports.ImportedReport dataclass

ImportedReport(
    asset_id: str, name: str, path: Path, sha256: str
)

One registered-and-uploaded PDF.

methodic.types.Scope dataclass

Scope(
    id: str,
    name: str,
    kind: str,
    slug: str = "",
    organization_id: str | None = None,
)

One scope the caller can operate as — their personal space, or a team or organization they belong to. Mirrors the server's UserScope.

kind is "user" | "team" | "organization". id is the principal id you pass as organization_id / team_id elsewhere (e.g. experiments.move(...)). slug may be empty for a sparse personal scope that has no homepage yet. For a team scope, organization_id is the team's parent organization (a team always sits inside one org); None for user + organization scopes — so a key/experiment created under a team can record both organization_id and team_id.

Errors

methodic.ChronicleError

Bases: Exception

Base class for every error raised by the methodic client.

methodic.APIError

APIError(
    status_code: int,
    message: str,
    response: Response | None = None,
)

Bases: ChronicleError

HTTP error from the Chronicle API.

methodic.AuthenticationError

AuthenticationError(
    status_code: int,
    message: str,
    response: Response | None = None,
)

Bases: APIError

401 — missing or invalid credentials.

methodic.PermissionDeniedError

PermissionDeniedError(
    status_code: int,
    message: str,
    response: Response | None = None,
)

Bases: APIError

403 — caller lacks the required ACL grants.

methodic.NotFoundError

NotFoundError(
    status_code: int,
    message: str,
    response: Response | None = None,
)

Bases: APIError

404 — resource does not exist or is hidden by RBAC.

methodic.BadRequestError

BadRequestError(
    status_code: int,
    message: str,
    response: Response | None = None,
)

Bases: APIError

400/422 — malformed request body or invalid arguments.

methodic.ConflictError

ConflictError(
    status_code: int,
    message: str,
    response: Response | None = None,
)

Bases: APIError

409 — state conflict (e.g., commit on already-committed experiment).

methodic.ServerError

ServerError(
    status_code: int,
    message: str,
    response: Response | None = None,
)

Bases: APIError

5xx — Chronicle is unreachable, misconfigured, or buggy.

methodic.ChronicleConfigError

Bases: ChronicleError

Client configuration could not be resolved — a missing API key, or a malformed config file / environment value. Raised by Chronicle.from_env and Chronicle.from_file before any HTTP request is made.