ai-tally (Python SDK)
The Python SDK for AI Tally: cost-and-value observability for AI
products. It meters what each AI call costs, attributes the spend to your features and to your
own customers, and sends only counts and hashes, never prompts or answers. Spans use the
OpenTelemetry gen_ai.* attributes, with cost, feature-tag, identity and agent extensions.
pip install ai-tally
The SDK has no runtime dependencies, and it never raises into your code: an internal error is caught at the SDK boundary, recorded, and your call proceeds.
Full documentation: https://ai-tally.com/docs/connect/python-sdk
Quick start
aitally.init(key) auto-instruments the official openai, anthropic and google-genai
clients and Bedrock converse through boto3, fetches your organization's hashing key in the
background, and starts a batching transport to the ingest endpoint. It needs no tenant id: the
ingest key already identifies your organization.
import aitally
aitally.init("tally_sk_live_...") # falls back to TALLY_KEY / TALLY_ENDPOINT env
# From here, unmodified provider calls are metered automatically, no record_* calls:
client.chat.completions.create(model="gpt-4o-mini", messages=[...]) # openai
client.messages.create(model="claude-sonnet-4-5", messages=[...]) # anthropic
client.models.generate_content(model="gemini-2.5-flash", contents=...) # google-genai
bedrock.converse(modelId="us.amazon.nova-pro-v1:0", messages=[...]) # boto3 bedrock-runtime
init is idempotent, never blocks the calling thread, and never raises: a bad key, an unreachable
gateway, or a missing provider library degrades to unattributed or disabled instrumentation with a
one-time warning. None of these libraries is a dependency of the SDK; each is patched only if it is
already installed.
The endpoint must use TLS
Every request carries the ingest key as a Bearer token, and the hashing-key fetch receives your organization's key material, so the SDK only talks to its endpoint over TLS:
https://is always allowed.http://is allowed without an opt-in only to a loopback host:localhost, a*.localhostname,127.0.0.0/8or[::1], so a local stack athttp://localhost:8080keeps working.- Any other
http://host (a compose service name likehttp://gateway:8080, a LAN IP) needs an explicit opt-in:aitally.init(key, endpoint=..., allow_insecure_http=True), orTALLY_ALLOW_INSECURE_HTTP=1in the environment. With it the SDK works and logs one warning naming the host (never the key). An explicitallow_insecure_http=Falsewins over the env var. - Any other scheme, a URL with no host, and a URL carrying userinfo (
user:pw@), a query or a fragment are refused even with the opt-in.
The host is compared after parsing the URL, so http://localhost.evil.com and
http://127.0.0.1@evil.com are not loopback, and a host with whitespace, control characters or
percent-escapes is refused. Loopback requests bypass http_proxy, so a configured proxy never sees
the key in cleartext. A *.localhost name is trusted by name: most resolvers answer it locally
(RFC 6761), but one that appends a search domain may not, so prefer localhost or 127.0.0.1 if
yours does. hash_account() called before aitally.init() has finished its key fetch fetches on
its own and reads only TALLY_ALLOW_INSECURE_HTTP, not the init() argument.
A refused endpoint behaves like a missing key: aitally.init() logs one warning and disables
telemetry rather than raising, and no request is sent. The same rule applies everywhere a request
URL is built: fetch_hmac_key, a directly built BatchingTransport (which raises
InsecureEndpointError, a ValueError), hash_account and the tally CLI (which exits 1 with
the reason). The SDK does not follow HTTP redirects from the endpoint, since urllib would carry the
key to the redirect target: a batch answered with a 3xx is dropped and counted as rejected
(rejected_span_count), not retried, and a hashing-key fetch answered with one fails and leaves
accounts unattributed. Point endpoint at the gateway's final URL.
What aitally.init wraps
| Library | Calls metered | Sync | Async | Streaming |
|---|---|---|---|---|
openai |
chat.completions.create, responses.create, embeddings.create |
yes | yes | yes |
anthropic |
messages.create, messages.stream |
yes | yes | yes |
google-genai |
models.generate_content, models.generate_content_stream |
yes | yes (client.aio) |
yes |
boto3 / botocore |
bedrock-runtime converse, converse_stream |
yes | no (aiobotocore is not patched) |
yes |
Not wrapped, so still an aitally.record_llm_call after the call: Bedrock invoke_model and
invoke_model_with_response_stream (their usage is in a model-specific body, or in undocumented
headers with no cache-read count, so a figure from them could silently undercount), the legacy
google-generativeai package, and any other client library. For LiteLLM, see "LiteLLM" below.
- Provider name for OpenAI-compatible hosts. The
openailibrary also talks to Fireworks, Groq, Gemini's OpenAI-compatible endpoint and others, so each call is labeled from the client'sbase_urlhost, not assumed to be OpenAI:api.openai.comisopenai,api.fireworks.aiisfireworks-ai,generativelanguage.googleapis.comisgoogle, and Groq, Together, DeepSeek, xAI, OpenRouter, Mistral and Azure get their own names (their calls show a blank cost until the catalog prices them, never OpenAI's rates). A host that matches nothing is recorded asopenai-compatible, which no catalog row prices. Only the host is read; the URL never reaches the span. OpenAI's own data-residency hosts (eu.api.openai.com,us.api.openai.com) areopenai. - Gemini tokens.
prompt_token_countplustool_use_prompt_token_countis input,candidates_token_countplusthoughts_token_count(thinking, billed as output) is output, andcached_content_token_countis the cached share of input. With automatic function calling, each round trip is its own span, because each is billed. - Bedrock model ids.
us.amazon.nova-pro-v1:0and foundation-model ARNs are recorded asamazon.nova-pro, the catalog's key. An ARN whose model cannot be read (an application inference profile, a provisioned model) records no model and no cost rather than the ARN. Bedrock'sinputTokensexcludes cached tokens, so both cache buckets are added back into input. Aconverse_streamspan is emitted once the caller has read the stream to its end. - Streaming tokens. OpenAI reports usage only when
stream_options={"include_usage": True}is set. By default a stream without it emits a span with null token counts (an honest blank, never a fabricated zero). Passaitally.init(..., instrument_stream_usage=True)to have the OpenAI wrapper addinclude_usagewhen the caller did not, so streamed calls price fully. - Accounts. Set the customer once with
with_account("acct_..."); every auto-instrumented span in the scope carries the HMAC'd account hash, computed in-process under your organization's key. Until the key fetch completes (or if it fails), accounts land unattributed, never as a raw id. - What is automatic vs. app-side. The one-liner captures LLM provider calls only. Vector search
(
aitally.record_vector_call), your own tool calls (aitally.record_tool_call), and embeddings not made through the patched client (aitally.record_embedding_call) remain explicit one-liners that delegate to the process-global client. These are safe no-ops beforeinit. - Lifecycle.
aitally.flush()drains buffered spans (also drained atatexit);aitally.uninstrument()reverses all patches and tears the client down (useful in tests). - Choosing which clients are patched.
instrument=True(the default) patches every library in the table above,instrument=Falsepatches none, and a collection of names patches only those:"openai"(which also covers OpenAI-compatible hosts reached through theopenailibrary, Fireworks included),"anthropic","google"(google-genai;"gemini"works too) and"bedrock". For exampleaitally.init(key, instrument={"openai", "anthropic"}). - Clients pointed at the ai-tally proxy are left alone. The proxy already records those calls,
so a client whose base URL is an ai-tally proxy route (
*.proxy.ai-tally.com, oringest.ai-tally.comunder/openai,/anthropicor/gemini) or that sends the proxy'sX-Tenant-Keyheader is passed straight through rather than metered a second time. The header check is what covers a self-hosted proxy on your own domain.
Manual lines for Gemini and Bedrock Converse
If your code calls aitally.record_llm_call(...) after a google-genai or Bedrock converse
call, delete that line: aitally.init() records those calls automatically. To keep metering a
provider by hand instead, leave it out of instrument: instrument={"openai", "anthropic"} keeps
Gemini and Bedrock manual.
Until you do, a safety net keeps the count right. When a record_llm_call matches a call the SDK
recorded automatically moments earlier (same provider, model and token counts, on the same client,
in the same thread or asyncio task, within a few seconds), the manual copy is not sent, and a
one-time warning names the fix. Two identical calls with two manual lines still count as two calls,
and a manual call with no automatic counterpart is always sent. The net only sees a manual line
that runs in the same thread or task as the call it duplicates, so treat it as a net, not a fix.
Hashing an account for the proxy path
The zero-code proxy holds no HMAC key. To send a pre-hashed X-Tally-Account-Id-Hash, compute it
on your own machine with the same key the SDK uses:
from aitally import hash_account
h = hash_account("acct_northwind") # uses your organization's key
python -m aitally.hash_account acct_northwind # CLI form, reads TALLY_KEY / TALLY_ENDPOINT
Command line
The SDK installs a tally command. It needs no extra and uses the same key and endpoint as
aitally.init().
tally login # reads a key without echoing it; stores it 0600 in ~/.config/tally/config.ini
tally health # the key's scope and the last 24 h of metered model calls
tally keys # the organization's keys, metadata only (a write or admin key)
tally test-event # sends one span with no model, tokens, cost or body; prints what was accepted
tally spend --month 2026-10 # the month's metered spend by feature and provider
TALLY_KEY wins over the stored key, so CI needs no config file; TALLY_CONFIG moves the file;
--endpoint or TALLY_ENDPOINT points it at a self-hosted gateway, under the same TLS rule as
aitally.init() (see "The endpoint must use TLS" above; TALLY_ALLOW_INSECURE_HTTP=1 for a
non-loopback http:// host). The key is never printed, not even in part. A figure the gateway
could not read prints as unknown with the reason, never 0, and a month with unpriced model calls
prints its total as "at least". Every read command takes --json.
Tagging spend with a customer account
An account_id says which of your customers a call belongs to. It is what turns a cost total
into cost per customer.
The id is context-scoped: a web app knows the customer once, at request start, so you set it once and every span inside the scope picks it up. Requiring it on every call would be noise, and noise gets skipped.
import aitally
aitally.init("tally_sk_live_...")
# In a request middleware: resolve the customer once, wrap the handler.
with aitally.start_trace(feature_tag="support-bot"), aitally.with_account("acct_northwind"):
client.chat.completions.create(model="gpt-4o", messages=[...]) # metered automatically
aitally.record_tool_call(provider="openai", tool="web_search")
# ...every span emitted inside this block carries the same account.
One call can override the scope, which is what a batch job that walks several customers needs:
import aitally
from aitally.pricing import Usage
with aitally.start_trace():
for account in accounts:
aitally.record_llm_call(
provider="openai", model="gpt-4o",
usage=Usage(input_tokens=1200, output_tokens=300),
account_id=account,
)
with_account(None) clears the account for a block. That is the opt-out for a background task
that must not inherit its caller's customer.
What actually goes on the wire
The raw account_id never leaves your process. It is HMAC-SHA256'd under your organization's own
key at emit time, exactly like a user id, and the span carries only:
| Attribute | Meaning |
|---|---|
gen_ai.account_id_hash |
HMAC-SHA256 hex of the account id |
gen_ai.account_id_hash_key_version |
the key version that produced it, so rotation does not orphan history |
gen_ai.account_label |
optional display name (see below) |
Because the key is per organization, the same account id hashes differently for two different organizations, so an account cannot be correlated across them.
If the key is not available yet (or the fetch failed), the span is emitted unattributed with a one-time warning. It is never dropped and the raw id is never substituted.
The optional label
with aitally.with_account("acct_northwind", label="Northwind Traders"):
...
The label is wire-only. The gateway upserts it into a label store keyed on the account hash and does not write it to the span row, so no customer name lands in the telemetry store. Labels are optional per account: set none and the dashboard falls back to a shortened hash, which is a supported way to run. A label with no account id alongside it is dropped, since there is nothing to key it on.
LiteLLM
If your app calls models through LiteLLM, install the extra and add the logger as a LiteLLM callback:
pip install "ai-tally[litellm]"
import litellm
import aitally
from aitally.integrations.litellm import TallyLiteLLMLogger
aitally.init("tally_sk_live_...", instrument=False)
litellm.callbacks = [TallyLiteLLMLogger()]
litellm.completion(
model="gemini/gemini-2.5-flash",
messages=[...],
litellm_metadata={
"tally_account": customer_id, # hashed before it leaves the process
"tally_feature": "title_search.main", # shows as the feature
"tally_session": session_id, # optional
"tally_account_label": "Northwind", # optional, human-readable name
},
)
-
instrument=Falseis required: some LiteLLM calls go through theopenailibrary, whichaitally.init()would otherwise patch and record a second time. With it off, direct provider calls in the same process are not metered, so send those through LiteLLM too or record them withaitally.record_llm_call. -
Put the Tally keys in
litellm_metadata, notmetadata. LiteLLM forwardsmetadatato the provider on some endpoints;litellm_metadatais never forwarded. -
A call without
tally_accountis recorded unattributed: its cost counts toward your total but not toward any customer. -
Streams need
stream_options={"include_usage": True}, except on Anthropic and Gemini, which report usage on streams without being asked. Without it the token counts and cost are blank. -
Shut the logger down before the process ends. A short script that ends normally is covered by an exit hook, but in an async script you must await
ashutdown()beforeasyncio.run(main())returns, or asyncio cancels the callbacks still in flight and those calls are not recorded:from aitally.integrations.litellm import ashutdown, shutdown shutdown(timeout_s=5) # batch jobs, after your event loop has finished await ashutdown(timeout_s=5) # inside a running loop, such as a server's shutdown hook
-
The extra pins LiteLLM 1.105.1, the version the logger was checked against. On any other version the logger still runs, but it records null token counts and logs that once at startup.
Local replay samples
For teams whose request text must not leave their servers, the SDK can keep replay samples on the machine that already holds them instead of sending them to AI Tally:
aitally.init(replay_capture="local", replay_dir="/var/lib/tally/replay")
It captures only while an admin has turned on local capture for the organization (a switch of its
own, separate from hosted replay), samples 1% of non-streamed calls (replay_sample_rate), and
keeps 7 days (replay_retention_days). Nothing is sent: no provider key, no headers, and no body
leaves the box. The files hold real prompts and answers, so your own data rules apply to that
folder.
Files are <replay_dir>/<UTC day>/<host>-<pid>.jsonl.gz (a +<n> suffix after a failed write),
with a .tally-replay marker at the top.
The SDK only ever deletes YYYY-MM-DD/*.jsonl.gz files in a folder carrying that marker, and never
follows a symlink. When local capture is turned off for the organization, the SDK deletes every
sample file under replay_dir. To delete them yourself:
tally eval purge --samples /var/lib/tally/replay # everything
tally eval purge --samples /var/lib/tally/replay --before 2026-10-01 # only older days
aitally.purge_local_samples(replay_dir, before=...) does the same from code.
Running an eval on your own server
pip install openai anthropic # whichever providers your candidates and judge use
tally eval --samples /var/lib/tally/replay --models gpt-4o-mini,claude-haiku-5-5 --max-spend-usd 5
Each sample is replayed on every candidate with your own provider keys (OPENAI_API_KEY,
ANTHROPIC_API_KEY, read by the provider libraries, never by the SDK). A judge then compares each
candidate's answer with the answer your product actually gave, using the same rubric as AI Tally's
hosted Compare. You get one row per candidate: replay cost, judge cost, win rate against the
original answer (with a 95% interval), refusal rate, and how many samples were scored, skipped
and failed. Skipped and failed samples never count as losses. A figure that can't be known prints
as unknown, never $0.00.
- The judge is your organization's judge model unless you pass
--judge(oraitally.init(eval_judge_model=...)). --max-spend-usdcovers candidate and judge calls together. Every call reserves its worst case before it is made, and no call is made that could pass the cap. The command prints the worst case and asks before it starts;--yesskips the question in CI.- Every call is sent with an output cap:
--max-output-tokensand--judge-max-output-tokens, 1024 by default. An answer cut off at the cap counts as failed. --format jsonalso checks that each answer is valid JSON.--jsonprints the table as JSON.--reportsends the numbers (model names, counts, costs, rubric version, no prompts or answers) to AI Tally, where the run shows on Compare as run on your server.
tally eval refuses to run while local capture is turned off for the organization. If an admin
turned it off, it also deletes the samples. aitally.run_local_eval(...) does the same from code.
Onboarding MCP server
aitally.init() meters the LLM layer on its own, but the other layers need app code: the
vector, tool and embedding record_* calls, and the per-customer attribution only your app
can resolve. The onboarding MCP server hands your own coding agent the maintained recipes and
generated snippets for that wiring. Your source never leaves your machine: the server holds
only the recipe catalog and the SDK surface, and it returns code, never an applied edit.
Install the SDK with the mcp extra (it is optional, the SDK runtime itself has no
dependencies):
pip install "ai-tally[mcp]"
That installs the tally-onboarding-mcp console script, which speaks MCP over stdio.
Every example below passes your ingest key as TALLY_KEY (the same tally_sk_... key
aitally.init() uses, from the dashboard's Setup page). Only coverage_report uses it, to check
which layers are reaching AI Tally; the other tools work offline without it.
Claude Code
claude mcp add ai-tally-onboarding --env TALLY_KEY=tally_sk_... -- tally-onboarding-mcp
Or add it to .mcp.json in your project root so your team picks it up too. Keep the key out of
the committed file: ${TALLY_KEY} is expanded from each developer's own environment.
{
"mcpServers": {
"ai-tally-onboarding": {
"command": "tally-onboarding-mcp",
"args": [],
"env": { "TALLY_KEY": "${TALLY_KEY}" }
}
}
}
Cursor
Add this stanza to ~/.cursor/mcp.json (global) with your key in env. A project-level
.cursor/mcp.json works too, but a file holding a key must never be committed:
{
"mcpServers": {
"ai-tally-onboarding": {
"command": "tally-onboarding-mcp",
"args": [],
"env": { "TALLY_KEY": "tally_sk_..." }
}
}
}
If tally-onboarding-mcp is not on your agent's PATH, point command at the interpreter that
has it installed instead: "command": "/path/to/.venv/bin/tally-onboarding-mcp".
The tools
| Tool | What it returns |
|---|---|
detect_stack |
the app language (python or node), plus providers, agent frameworks, vector DBs, OpenTelemetry SDKs and web frameworks found in a manifest (a Python manifest, or a package.json on its own), the recipe ids that match, the already_covered notes for layers you must not meter twice, and the gaps that matched nothing |
get_recipe |
one machine-readable recipe, by id or by a friendly name (pinecone, fastapi) |
generate_startup |
the aitally.init() line for application startup |
generate_middleware |
account / feature middleware bound to the resolver you confirm, bundled with the startup line under startup (a generated snippet, or a gap with code: None if the catalog has no startup recipe, so startup["code"] is always safe to read) |
instrument_call_site |
the adapted record_* edit for one concrete call site |
explain_layer |
which record_* method covers a layer and why, grounded on the live SDK surface |
coverage_report |
per-layer coverage read from the gateway's probe: which layers a real span proves are flowing, and why each dark layer is dark |
Configuring coverage_report
coverage_report is the only tool that talks to anything outside your machine. It calls the
gateway's coverage probe, which is the side that can read your telemetry.
| Variable | What it is |
|---|---|
TALLY_KEY |
your ingest key. The gateway reads your organization from the key, so the report can only ever describe your own data. Any key scope works: the probe returns span counts per layer and nothing else |
TALLY_GATEWAY_URL |
optional. Defaults to $TALLY_ENDPOINT if you set one for the SDK, else the hosted ingest host the SDK posts to. Must be https://, because the request carries your key; plain http:// is accepted only for localhost, 127.0.0.1 or ::1 so local dev still works |
TALLY_MCP_TENANT_ID |
optional. If set beside TALLY_KEY it must be your organization id (shown on the dashboard's connector setup), and it is sent as x-tenant-id so the gateway checks it against the key. A key for a different organization is then refused with a message naming both variables |
The key is held by reference: the tool reads the variable at the moment it calls the probe and
never stores, echoes or logs the value. The tenant_key argument is likewise reported only as
present or absent, never sent onward. The key is also never carried off the origin you
configured: a redirect to a different scheme, host or port is refused rather than followed, so a
load balancer that redirects elsewhere cannot be handed your key.
Leave these unset and the tool answers probe_available: false with every layer unknown and
the reason attached. That is also what you get if TALLY_GATEWAY_URL is rejected as unsafe, or
if the gateway is unreachable, answers an error, times out, redirects off-origin, or returns
something unreadable or oversized. reason is always present (empty when the probe answered).
None of those ever turns into "this layer is not wired": a layer is reported covered only when
the probe returns a span count above zero to prove it, re-derived from that count rather than
taken on the wire's word, so a payload claiming coverage with no evidence is downgraded back to
unknown.
Pass wired (the layers you just instrumented) to separate "wired, awaiting first event" from
"not wired" on a dark layer. It only softens the wording; it can never produce coverage.
Two things the server will not do. It never guesses which customer a request belongs to: pass
generate_middleware the header or resolver you confirmed, and with no answer it returns a
reported gap and the account layer stays unattributed. And it never invents an SDK call: a
stack with no recipe comes back as a gap, and every hole it cannot fill from the call site you
passed is left as a visible <FILL:...> marker for you to complete.
One thing the server will not let you do twice. aitally.init() already patches the openai,
anthropic and google-genai clients and Bedrock converse in your process, so those call sites
need no edit. detect_stack reports any such provider under already_covered, and the manual
llm.generic.call recipe deliberately does not match a patched call shape: adding
record_llm_call beside one would meter the same call twice and double your reported cost. Use
that recipe only where the patch does not reach, such as a raw /v1/chat/completions POST,
Bedrock invoke_model, ollama, or a self-hosted or gateway-fronted model.
Both mcp 1.x (FastMCP, 1.2.0 and up) and mcp 2.x (MCPServer) are supported. Without the
extra installed, launching the server fails with a clear error rather than starting a server
that does nothing. The extra is also what makes the tools usable at all: onboarding_mcp ships
in the base wheel so the console script resolves, but the recipe catalog is YAML and pyyaml
comes with the extra, since the SDK runtime itself stays dependency-free.
Node and TypeScript apps
The SDK is Python, so for a Node or TypeScript app the agent wires the two paths that need no
SDK, and the server still runs from this Python package on your machine. Pass detect_stack your
package.json on its own (both dependencies and devDependencies are read) and it answers
language: "node" with only Node recipes; a Python manifest never gets them, and a
package.json never gets Python SDK code.
| Recipe | When it matches | What it wires |
|---|---|---|
proxy.openai.base_url |
openai, @ai-sdk/openai |
baseURL: "<ingest>/openai/v1" plus an X-Tenant-Key default header on the client (or the Vercel AI SDK provider factory) |
proxy.anthropic.base_url |
@anthropic-ai/sdk, @ai-sdk/anthropic |
baseURL: "<ingest>/anthropic" plus the same header (<ingest>/anthropic/v1 for @ai-sdk/anthropic, whose base URL carries the /v1) |
otel.node |
@opentelemetry/*, @traceloop/* (OpenLLMetry) |
the OTLP/HTTP JSON trace exporter pointed at /v1/otlp/traces, as otel.ingest.gen_ai does for Python |
account.node.header_hash |
whenever one of the above matches | cost per customer: the pre-hashed X-Tally-Account-Id-Hash header (or gen_ai.account_id_hash span attribute) |
<ingest> is your deployment's ingest URL, https://ingest.ai-tally.com on the hosted service.
The proxy URLs are the ones the dashboard's connect snippets show. On the proxy path your provider
key is forwarded to the provider unchanged and is not recorded, but it does reach AI Tally in
transit; the OTel path never sends it. The proxy answers 403 until an organization admin turns
it on under Settings > API keys.
Per-customer attribution is the same floor as in Python: the agent asks how your app knows the
customer and never guesses. The proxy holds no HMAC key and will not hash a raw id, so the
account recipe computes the HMAC-SHA256 digest in your process under your organization's key
from /v1/tenant/hmac-key (the same digest the Python SDK produces) and sends only that. A raw
customer id is never sent. Without the header the spend lands unattributed, reported as a blank
rather than a guessed customer.
If a Node app has both a proxied client and an OpenTelemetry setup that emits gen_ai.* spans,
detect_stack flags it under already_covered: pick one path per call, or the call is metered
twice. Providers with no proxy recipe yet (Gemini, Bedrock, Mistral, Cohere, Groq), a Vercel AI
SDK app with no OpenAI or Anthropic provider package, and Node vector databases come back as
named gaps. instrument_call_site, generate_middleware and explain_layer stay Python-only,
since they emit or explain Python SDK calls; for a Node framework such as express,
generate_middleware answers with a gap that points at account.node.header_hash. The repo PR
bot below reads Python repos only.
Repo PR bot
The MCP server above hands the recipes to your own coding agent. The PR bot is the other end
of the same catalog: give it scoped access to a repo and it runs the loop for you and opens a
pull request for your review. Same recipes, same refusals, no coding agent needed. It is a
command you run, not a GitHub App, so the access you grant is a token you hold and can take
back. Like the MCP server, it needs the mcp extra.
export TALLY_ONBOARDING_GITHUB_TOKEN=github_pat_...
tally-onboarding-bot \
--repo acme/widgets \
--account-source 'request.headers.get("X-Customer-Id")' \
--feature-tag support-bot
Omit --account-source and the bot does not pick one for you: it opens the PR carrying the
account question and the candidate resolvers it found, leaves the account layer
unattributed, and says so in the body. --dry-run proposes the diff and stops before
creating a branch.
Supplying and revoking the token
- In GitHub, go to Settings, Developer settings, Personal access tokens, Fine-grained tokens, and generate a token whose repository access is only the repo you want the PR in.
- Give it exactly two repository permissions: Contents: read and write (to push the new branch) and Pull requests: read and write (to open the PR). Nothing else is used. Set the shortest expiry you can live with.
- Put the value in the environment variable named by
--token-env(defaultTALLY_ONBOARDING_GITHUB_TOKEN), or in your secret manager and inject it from there. The bot holds the variable NAME, never the value: it reads it at the moment git or the API needs it, hands it to git throughGIT_ASKPASSso it never lands in.git/configor in a process listing, and redacts it from anything it prints. - To revoke, delete the token on that same page, or unset the variable. There is no installation to uninstall and no stored copy to clean up.
What it refuses to do
Enforced in code (onboarding_bot/guards.py), not merely promised here:
- It never pushes to a default branch. Every push resolves the repo's default branch
first and refuses it, along with
main/master/trunk/developwhatever the remote reports. It pushes the one new branch it created, and force pushes are refused. - It never merges. Git subcommands and GitHub endpoints are both allowlisted;
merge(andrebase,cherry-pick,reset) is on neither, so no code path reaches one. The PR waits for a human. - It keeps no source. The clone is shallow, lives in a temporary directory, and is
deleted in a
finallyon every exit path including a refused run, and by a SIGTERM / SIGINT handler so a runner that stops the job does not leave the clone behind either. ASIGKILLcannot be caught by anything, so the promise is every exit path the process controls. A cleanup that fails is reported incleanup_error, never swallowed. What the run returns is paths, counts, generated code and gaps, never your source. - It invents nothing. Every line it writes comes from the recipe catalog. A value it
cannot derive from the call site stays a visible
<FILL:...>hole, and a block with a hole is inserted commented out so nothing runs on a guessed value. - It claims nothing it did not do.
layers_wired(in the JSON result, the commit subject and the PR table) lists only blocks that actually meter. A block inserted commented out is reported separately as inactive. - It never commits code that does not compile. Every block goes in at a statement
boundary resolved from the parsed file, and every patched Python file is run through
compile()before anything is written. A file it cannot patch cleanly is left untouched and reported as a gap.
Each run gets its own branch (tally/onboarding/<run id>); pass --branch-suffix to name
it yourself. If the branch is already on the remote the run stops and says so instead of
failing at push. If the push succeeds but the PR call does not, the exit is non-zero and the
printed result carries orphan_branch so you can find the branch it left.
License
MIT. See LICENSE.
Metadata
Release files for ai-tally 0.0.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| ai_tally-0.0.2.tar.gz | 712.5 kB | With provenance | Details for ai_tally-0.0.2.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| ai_tally-0.0.2-py3-none-any.whl | Python 3 | none | any | Details for ai_tally-0.0.2-py3-none-any.whl |
Total release size: 1.2 MB
Release files / ai_tally-0.0.2.tar.gz
| Download URL | ai_tally-0.0.2.tar.gz |
|---|---|
| Size | 712.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4b09a8efdd3b368ddc96cef24d326f7496490a797687787e06f326750f1b60f4
|
|
BLAKE2b-256 checksum How to use checksums |
9778de852577680195868e3d37781f0c7827700b55b5c94bab4d1c5e5b513fc7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 4, 2026.
Transparency logRelease files / ai_tally-0.0.2-py3-none-any.whl
| Download URL | ai_tally-0.0.2-py3-none-any.whl |
|---|---|
| Size | 486.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d139fb86563fb58505aafdcd4e7854ccc61e67ee9b18abe2ad204c9d471fab53
|
|
BLAKE2b-256 checksum How to use checksums |
eb3b6cf1450518cb13d1db9b2013e8d3f63a147c88959e59440eac5815f8d2b9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 4, 2026.
Transparency logSecurity for ai-tally 0.0.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for ai_tally-0.0.2.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 4, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.