{ "cells": [ { "cell_type": "markdown", "id": "2b0c5d04", "metadata": {}, "source": [ "# 00 · Provision — Amazon Connect TEI in Athena\n", "\n", "Creates everything this study needs in the Athena sandbox, end to end:\n", "\n", "1. **Report template** *Amazon Connect 2026* (3 years, 10% discount rate) + **field definitions**\n", "2. **Client selection** — browse the CRM, pick the client, and pull their profile (industry, agent counts, revenue) so nothing is re-entered\n", "3. **Attachment** — pick (or create) the **Proposal or Engagement** the tool binds to\n", "4. **Tool instance** + **seed values** from `seed_data.py` (the published Forrester figures)\n", "5. **Server-side calculation** and **verification** against the published totals: **NPV \\$78.7M · ROI 342% · payback <6 months**\n", "6. Persists all IDs to `.env` so the other notebooks, the CLI, and the Streamlit app pick them up automatically.\n", "\n", "Safe to re-run — every step finds existing objects before creating new ones." ] }, { "cell_type": "code", "execution_count": 5, "id": "5bcc7740", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "✅ Athena connected — https://athena.ouranos.helu.ca (1 report templates visible)\n", "📁 Study: 202602_AmazonConnect\n" ] } ], "source": [ "import sys, pathlib # path shim: works on a fresh kernel\n", "for _p in [pathlib.Path.cwd(), *pathlib.Path.cwd().parents]:\n", " if (_p / \"pyproject.toml\").exists():\n", " sys.path.insert(0, str(_p)); break\n", "\n", "import pandas as pd\n", "from core.bootstrap import init, update_env\n", "\n", "pal = init(study=\"202602_AmazonConnect\")\n", "client, seed, config = pal.client, pal.seed_data, pal.config\n", "assert pal.connection.get(\"status\") == \"ok\", \"Fix the connection first → 00_setup.ipynb\"" ] }, { "cell_type": "markdown", "id": "cc8a4e03", "metadata": {}, "source": [ "## 1 · Report template (find or create)" ] }, { "cell_type": "code", "execution_count": 6, "id": "386ae38b", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Found existing report template xsUTbjh4iDnJ (status: active)\n" ] } ], "source": [ "REPORT_NAME, VENDOR = \"Amazon Connect 2026\", \"AWS\"\n", "\n", "report = next(\n", " (r for r in client.list_reports()\n", " if r.get(\"name\") == REPORT_NAME and r.get(\"vendor\") == VENDOR),\n", " None,\n", ")\n", "if report is None:\n", " report = client.create_report(\n", " name=REPORT_NAME,\n", " vendor=VENDOR,\n", " version=\"1.0\",\n", " description=\"Forrester Total Economic Impact of Amazon Connect, Feb 2026\",\n", " analysis_period_years=seed.ASSUMPTIONS[\"analysis_years\"],\n", " discount_rate=seed.ASSUMPTIONS[\"discount_rate\"],\n", " status=\"draft\",\n", " )\n", " print(f\"Created report template {report['id']}\")\n", "else:\n", " print(f\"Found existing report template {report['id']} (status: {report.get('status')})\")\n", "\n", "REPORT_ID = report[\"id\"]" ] }, { "cell_type": "markdown", "id": "dab83777", "metadata": {}, "source": [ "## 2 · Field definitions\n", "\n", "Derived straight from `seed_data.py`. Three methodology notes:\n", "\n", "- **Benefit risk adjustment** lives on the field definition — Athena applies `value × (1 − risk_adj)` at calculate time.\n", "- **Costs**: Athena never risk-adjusts costs, but Forrester adjusts them *upward*. We therefore push cost values pre-multiplied by `(1 + risk_adj)` in step 5, and keep the field-level adjustment at 0 so nothing is applied twice.\n", "- **Year-0 \"Initial\" amounts** have no native slot in the TEI API, so each cost gets a companion non-annual `_initial` field. The client folds these back automatically on read." ] }, { "cell_type": "code", "execution_count": 7, "id": "dc46ab46", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "0 fields created, 11 already existed.\n" ] } ], "source": [ "def field_defs():\n", " defs, sort = [], 0\n", " for b in seed.BENEFITS:\n", " sort += 1\n", " defs.append({\n", " \"table\": \"benefits\",\n", " \"field_key\": b[\"field_key\"],\n", " \"label\": b[\"label\"],\n", " \"description\": b[\"notes\"][:200],\n", " \"field_type\": \"currency\",\n", " \"category\": b[\"category\"],\n", " \"is_annual\": True,\n", " \"risk_adjustment\": str(b[\"risk_adjustment\"]),\n", " \"sort_order\": sort,\n", " \"is_required\": True,\n", " \"source_notes\": b[\"notes\"],\n", " })\n", " for c in seed.COSTS:\n", " sort += 1\n", " defs.append({\n", " \"table\": \"costs\",\n", " \"field_key\": c[\"field_key\"],\n", " \"label\": c[\"label\"],\n", " \"description\": c[\"notes\"][:200],\n", " \"field_type\": \"currency\",\n", " \"category\": c[\"category\"],\n", " \"is_annual\": True,\n", " \"risk_adjustment\": \"0\", # applied client-side, see note above\n", " \"sort_order\": sort,\n", " \"is_required\": True,\n", " \"source_notes\": c[\"notes\"],\n", " })\n", " sort += 1\n", " defs.append({\n", " \"table\": \"costs\",\n", " \"field_key\": f\"{c['field_key']}_initial\",\n", " \"label\": f\"{c['label']} — initial (Year 0)\",\n", " \"description\": \"One-time Year-0 amount (companion field).\",\n", " \"field_type\": \"currency\",\n", " \"category\": c[\"category\"],\n", " \"is_annual\": False,\n", " \"risk_adjustment\": \"0\",\n", " \"sort_order\": sort,\n", " \"is_required\": False,\n", " \"source_notes\": \"Year-0 lump sum; Athena treats non-annual values as Year 1.\",\n", " })\n", " return defs\n", "\n", "existing = {f[\"field_key\"] for f in client.list_fields(REPORT_ID)}\n", "created = 0\n", "for d in field_defs():\n", " if d[\"field_key\"] not in existing:\n", " client.create_field(REPORT_ID, d)\n", " created += 1\n", "print(f\"{created} fields created, {len(existing)} already existed.\")\n", "\n", "if report.get(\"status\") == \"draft\":\n", " client.update_report(REPORT_ID, status=\"active\")\n", " print(\"Report template activated.\")" ] }, { "cell_type": "markdown", "id": "d5841b4e", "metadata": {}, "source": [ "## 3 · Select the client\n", "\n", "Browse the CRM. Adjust `CLIENT_SEARCH` to narrow the list, then set `CLIENT_ID`\n", "below (it auto-selects when exactly one client matches)." ] }, { "cell_type": "code", "execution_count": 12, "id": "4070b9c2", "metadata": {}, "outputs": [ { "ename": "AttributeError", "evalue": "'TEIClient' object has no attribute 'list_clients'", "output_type": "error", "traceback": [ "\u001b[31m---------------------------------------------------------------------------\u001b[39m", "\u001b[31mAttributeError\u001b[39m Traceback (most recent call last)", "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[12]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m CLIENT_SEARCH = \u001b[33m\"Global\"\u001b[39m \u001b[38;5;66;03m# e.g. \"Acme\" — empty lists everyone\u001b[39;00m\n\u001b[32m 2\u001b[39m \n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m clients = client.list_clients(search=CLIENT_SEARCH \u001b[38;5;28;01mor\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m)\n\u001b[32m 4\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m clients:\n\u001b[32m 5\u001b[39m display(pd.DataFrame(clients)[\n\u001b[32m 6\u001b[39m [c for c in (\"id\", \"name\", \"vertical\", \"client_type\", \"employee_count\",\n", "\u001b[31mAttributeError\u001b[39m: 'TEIClient' object has no attribute 'list_clients'" ] } ], "source": [ "CLIENT_SEARCH = \"\" # e.g. \"Acme\" — empty lists everyone\n", "\n", "clients = client.list_clients(search=CLIENT_SEARCH or None)\n", "if clients:\n", " display(pd.DataFrame(clients)[\n", " [c for c in (\"id\", \"name\", \"vertical\", \"client_type\", \"employee_count\",\n", " \"contact_center_agent_count\", \"supervisor_count\")\n", " if c in clients[0]]\n", " ])\n", "else:\n", " print(\"No clients found — create one in the Athena UI (Orbit → Clients) and re-run.\")" ] }, { "cell_type": "code", "execution_count": 10, "id": "4e97978c", "metadata": {}, "outputs": [ { "ename": "NameError", "evalue": "name 'clients' is not defined", "output_type": "error", "traceback": [ "\u001b[31m---------------------------------------------------------------------------\u001b[39m", "\u001b[31mNameError\u001b[39m Traceback (most recent call last)", "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[10]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m CLIENT_ID = \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;66;03m# ← set from the `id` column above, or leave for auto-pick\u001b[39;00m\n\u001b[32m 2\u001b[39m \n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m CLIENT_ID \u001b[38;5;28;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;28;01mand\u001b[39;00m len(clients) == \u001b[32m1\u001b[39m:\n\u001b[32m 4\u001b[39m CLIENT_ID = clients[\u001b[32m0\u001b[39m][\u001b[33m\"id\"\u001b[39m]\n\u001b[32m 5\u001b[39m print(f\"Auto-selected the only client: {clients[\u001b[32m0\u001b[39m][\u001b[33m'name'\u001b[39m]} (id={CLIENT_ID})\")\n\u001b[32m 6\u001b[39m \u001b[38;5;28;01massert\u001b[39;00m CLIENT_ID \u001b[38;5;28;01mis\u001b[39;00m \u001b[38;5;28;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m, \u001b[33m\"Set CLIENT_ID from the table above and re-run this cell.\"\u001b[39m\n", "\u001b[31mNameError\u001b[39m: name 'clients' is not defined" ] } ], "source": [ "CLIENT_ID = None # ← set from the `id` column above, or leave for auto-pick\n", "\n", "if CLIENT_ID is None and len(clients) == 1:\n", " CLIENT_ID = clients[0][\"id\"]\n", " print(f\"Auto-selected the only client: {clients[0]['name']} (id={CLIENT_ID})\")\n", "assert CLIENT_ID is not None, \"Set CLIENT_ID from the table above and re-run this cell.\"\n", "\n", "profile = client.client_profile(CLIENT_ID)\n", "CLIENT_NAME = profile[\"name\"]\n", "print(f\"\\nClient profile — no re-entry needed downstream:\")\n", "display(pd.DataFrame([profile]).T.rename(columns={0: CLIENT_NAME}))" ] }, { "cell_type": "markdown", "id": "0ae76599", "metadata": {}, "source": [ "### Client data → study assumptions\n", "\n", "Where the CRM has real numbers, they override the Forrester composite\n", "(2,000 agents / 200 supervisors). `CLIENT_ASSUMPTIONS` is what `01_benefits.ipynb`\n", "uses for scaling discussions." ] }, { "cell_type": "code", "execution_count": 11, "id": "fcccc591", "metadata": {}, "outputs": [ { "ename": "NameError", "evalue": "name 'profile' is not defined", "output_type": "error", "traceback": [ "\u001b[31m---------------------------------------------------------------------------\u001b[39m", "\u001b[31mNameError\u001b[39m Traceback (most recent call last)", "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[11]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m CLIENT_ASSUMPTIONS = dict(seed.ASSUMPTIONS)\n\u001b[32m 2\u001b[39m overrides = {\n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[33m\"agents_fte\"\u001b[39m: profile.get(\u001b[33m\"contact_center_agent_count\"\u001b[39m),\n\u001b[32m 4\u001b[39m \u001b[33m\"supervisors_fte\"\u001b[39m: profile.get(\u001b[33m\"supervisor_count\"\u001b[39m),\n\u001b[32m 5\u001b[39m }\n\u001b[32m 6\u001b[39m rows = []\n", "\u001b[31mNameError\u001b[39m: name 'profile' is not defined" ] } ], "source": [ "CLIENT_ASSUMPTIONS = dict(seed.ASSUMPTIONS)\n", "overrides = {\n", " \"agents_fte\": profile.get(\"contact_center_agent_count\"),\n", " \"supervisors_fte\": profile.get(\"supervisor_count\"),\n", "}\n", "rows = []\n", "for key, val in overrides.items():\n", " if val:\n", " rows.append({\"assumption\": key, \"Forrester composite\": seed.ASSUMPTIONS[key],\n", " f\"{CLIENT_NAME} (CRM)\": val})\n", " CLIENT_ASSUMPTIONS[key] = val\n", "\n", "if rows:\n", " display(pd.DataFrame(rows))\n", " scale = CLIENT_ASSUMPTIONS[\"agents_fte\"] / seed.ASSUMPTIONS[\"agents_fte\"]\n", " print(f\"Indicative scale factor vs composite: {scale:.2f}× \"\n", " f\"(apply judgement — benefits don't all scale linearly)\")\n", "else:\n", " print(\"CRM has no agent/supervisor counts for this client — using the \"\n", " \"Forrester composite organization as-is.\")" ] }, { "cell_type": "markdown", "id": "422c2ed6", "metadata": {}, "source": [ "## 4 · Pick the attachment — Proposal or Engagement\n", "\n", "A TEI tool must attach to exactly one of the two. Both lists below are\n", "already filtered to the selected client." ] }, { "cell_type": "code", "execution_count": null, "id": "57dec6cf", "metadata": {}, "outputs": [], "source": [ "proposals = client.proposals_for_client(CLIENT_ID)\n", "engagements = client.engagements_for_client(CLIENT_NAME)\n", "\n", "if proposals:\n", " print(f\"Proposals for {CLIENT_NAME}:\")\n", " display(pd.DataFrame([\n", " {\"id\": p[\"id\"], \"name\": p.get(\"name\"), \"status\": p.get(\"status\"),\n", " \"opportunity\": (p.get(\"opportunity\") or {}).get(\"name\"),\n", " \"due_date\": p.get(\"due_date\")}\n", " for p in proposals\n", " ]))\n", "if engagements:\n", " print(f\"Engagements for {CLIENT_NAME}:\")\n", " display(pd.DataFrame([\n", " {\"id\": e[\"id\"], \"name\": e.get(\"name\"), \"status\": e.get(\"status\"),\n", " \"start\": e.get(\"start_date\"), \"end\": e.get(\"end_date\")}\n", " for e in engagements\n", " ]))\n", "if not proposals and not engagements:\n", " print(f\"{CLIENT_NAME} has no proposals or engagements yet — \"\n", " \"the next cell can create a sandbox opportunity + proposal.\")" ] }, { "cell_type": "code", "execution_count": null, "id": "19336bcc", "metadata": {}, "outputs": [], "source": [ "# Set exactly ONE of these (ids from the tables above). Leave both None to\n", "# auto-pick — single existing proposal/engagement wins; otherwise a sandbox\n", "# opportunity + proposal is created for the client.\n", "PROPOSAL_ID = config.PROPOSAL_ID # or e.g. 42\n", "ENGAGEMENT_ID = config.ENGAGEMENT_ID # or e.g. 7\n", "\n", "if PROPOSAL_ID is None and ENGAGEMENT_ID is None:\n", " if len(proposals) == 1 and not engagements:\n", " PROPOSAL_ID = proposals[0][\"id\"]\n", " print(f\"Auto-selected proposal {PROPOSAL_ID}: {proposals[0].get('name')}\")\n", " elif len(engagements) == 1 and not proposals:\n", " ENGAGEMENT_ID = engagements[0][\"id\"]\n", " print(f\"Auto-selected engagement {ENGAGEMENT_ID}: {engagements[0].get('name')}\")\n", " elif not proposals and not engagements:\n", " opp = client.create_opportunity(\n", " name=f\"{CLIENT_NAME} — CX Transformation (sandbox)\",\n", " client_id=CLIENT_ID,\n", " description=\"Created by Palladium 00_provision for the Amazon Connect TEI.\",\n", " )\n", " prop = client.create_proposal(\n", " name=f\"{CLIENT_NAME} — Amazon Connect TEI (sandbox)\",\n", " opportunity_id=opp[\"id\"],\n", " status=\"Draft\",\n", " )\n", " PROPOSAL_ID = prop[\"id\"]\n", " print(f\"Created opportunity {opp['id']} and proposal {PROPOSAL_ID} for {CLIENT_NAME}.\")\n", " else:\n", " raise SystemExit(\"Multiple options — set PROPOSAL_ID or ENGAGEMENT_ID above and re-run.\")\n", "\n", "assert (PROPOSAL_ID is None) != (ENGAGEMENT_ID is None), \\\n", " \"Set exactly one of PROPOSAL_ID / ENGAGEMENT_ID.\"\n", "attach = {\"proposal\": PROPOSAL_ID} if PROPOSAL_ID else {\"engagement\": ENGAGEMENT_ID}\n", "print(f\"Attaching via: {attach}\")" ] }, { "cell_type": "markdown", "id": "0056bce9", "metadata": {}, "source": [ "## 5 · Tool instance (find or create) & seed the published values" ] }, { "cell_type": "code", "execution_count": null, "id": "017ae9db", "metadata": {}, "outputs": [], "source": [ "from core.tei_client import AthenaAPIError\n", "\n", "def _report_id_of(t):\n", " r = t.get(\"report\")\n", " return r.get(\"id\") if isinstance(r, dict) else r\n", "\n", "def _matches_attachment(t):\n", " if PROPOSAL_ID is not None:\n", " opp = t.get(\"opportunity\") or {}\n", " return t.get(\"proposal\") == PROPOSAL_ID or opp.get(\"proposal_id\") == PROPOSAL_ID\n", " eng = t.get(\"engagement\")\n", " eng_id = eng.get(\"id\") if isinstance(eng, dict) else eng\n", " return eng_id == ENGAGEMENT_ID\n", "\n", "candidates = [t for t in client.list_tools() if _report_id_of(t) == REPORT_ID]\n", "tool = next((t for t in candidates if _matches_attachment(t)),\n", " candidates[0] if len(candidates) == 1 else None)\n", "\n", "if tool is None:\n", " try:\n", " tool = client.create_tool(\n", " report_public_id=REPORT_ID,\n", " name=f\"{CLIENT_NAME} — Amazon Connect TEI\",\n", " **attach,\n", " )\n", " print(f\"Created tool {tool['id']} attached to {attach}\")\n", " except AthenaAPIError as e:\n", " if e.status_code == 409: # DUPLICATE_INSTANCE\n", " raise SystemExit(\n", " \"An active tool already exists for this report + attachment. \"\n", " \"Find it with client.list_tools() or pick a different proposal/engagement.\"\n", " ) from e\n", " raise\n", "else:\n", " print(f\"Found existing tool {tool['id']} (status: {tool.get('status')})\")\n", "\n", "TOOL_ID = tool[\"id\"]" ] }, { "cell_type": "code", "execution_count": null, "id": "20e2a736", "metadata": {}, "outputs": [], "source": [ "payload = []\n", "for b in seed.BENEFITS: # nominal; Athena risk-adjusts via the field definition\n", " payload.append({\n", " \"field_key\": b[\"field_key\"],\n", " \"year_values\": b[\"year_values\"],\n", " \"notes\": b[\"notes\"],\n", " })\n", "for c in seed.COSTS: # risk-adjusted UP client-side (Forrester methodology)\n", " factor = 1 + c[\"risk_adjustment\"]\n", " payload.append({\n", " \"field_key\": c[\"field_key\"],\n", " \"year_values\": {y: round(v * factor, 2) for y, v in c[\"year_values\"].items()},\n", " \"initial\": round(c[\"initial\"] * factor, 2),\n", " \"notes\": c[\"notes\"],\n", " })\n", "\n", "client.update_values(TOOL_ID, payload)\n", "print(f\"Pushed values for {len(payload)} fields.\")" ] }, { "cell_type": "markdown", "id": "697794fd", "metadata": {}, "source": [ "## 6 · Calculate & verify against the published study" ] }, { "cell_type": "code", "execution_count": null, "id": "b7ac5d24", "metadata": {}, "outputs": [], "source": [ "summary = client.calculate(TOOL_ID)\n", "client.print_summary(TOOL_ID)" ] }, { "cell_type": "code", "execution_count": null, "id": "13d84001", "metadata": {}, "outputs": [], "source": [ "# Published Forrester totals (3-yr risk-adjusted PV @ 10%)\n", "PUBLISHED = {\n", " \"total_benefits_pv\": 101_696_791,\n", " \"total_costs_pv\": 22_983_076,\n", " \"net_present_value\": 78_713_715,\n", " \"roi_percentage\": 342,\n", "}\n", "# Tolerance: Athena discounts Year-0 'initial' amounts as Year 1 (Forrester\n", "# leaves Year 0 undiscounted) — expected drift is ~$0.1M on costs (≈0.15%).\n", "TOLERANCE = 0.02\n", "\n", "rows, ok = [], True\n", "for key, expected in PUBLISHED.items():\n", " actual = float(summary.get(key) or 0)\n", " diff = (actual - expected) / expected\n", " rows.append({\"metric\": key, \"published\": f\"{expected:,.0f}\",\n", " \"athena\": f\"{actual:,.0f}\", \"diff\": f\"{diff:+.2%}\"})\n", " ok &= abs(diff) <= TOLERANCE\n", "\n", "display(pd.DataFrame(rows))\n", "payback = summary.get(\"payback_period_months\")\n", "print(f\"Payback: {payback} months (published: <6 months)\")\n", "assert ok, f\"Server totals drifted more than {TOLERANCE:.0%} from the published study — investigate before proceeding.\"\n", "print(\"✅ Verified — Athena reproduces the published Forrester totals.\")" ] }, { "cell_type": "markdown", "id": "b48b6131", "metadata": {}, "source": [ "## 7 · Save a baseline version & persist IDs" ] }, { "cell_type": "code", "execution_count": null, "id": "148bdb2a", "metadata": {}, "outputs": [], "source": [ "if not client.list_versions(TOOL_ID):\n", " client.save_version(TOOL_ID, note=\"Baseline — published Forrester TEI figures (Feb 2026), moderate scenario.\")\n", " print(\"Saved version 1 (baseline).\")\n", "\n", "ids = {\n", " \"PALLADIUM_REPORT_PUBLIC_ID\": REPORT_ID,\n", " \"PALLADIUM_TOOL_PUBLIC_ID\": TOOL_ID,\n", "}\n", "if PROPOSAL_ID is not None:\n", " ids[\"PALLADIUM_PROPOSAL_ID\"] = str(PROPOSAL_ID)\n", "if ENGAGEMENT_ID is not None:\n", " ids[\"PALLADIUM_ENGAGEMENT_ID\"] = str(ENGAGEMENT_ID)\n", "\n", "env_path = update_env(**ids)\n", "print(f\"Saved to {env_path}:\")\n", "for k, v in ids.items():\n", " print(f\" {k}={v}\")" ] }, { "cell_type": "markdown", "id": "571b48c3", "metadata": {}, "source": [ "## Done\n", "\n", "The sandbox now has a live, calculated Amazon Connect TEI tool attached to\n", "the selected client's proposal/engagement — with the client's CRM profile\n", "(industry, agent counts, revenue) flowing into the tool automatically.\n", "\n", "- **Continue the analysis** → `01_benefits.ipynb` → `04_export.ipynb` (they pick up the IDs from `.env` via `config.py`)\n", "- **Interactive editing** → `make app` / `streamlit run app/main.py` — the tool appears in the sidebar\n", "- **CLI sanity check** → `python -m palladium summary $PALLADIUM_TOOL_PUBLIC_ID`" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.7" } }, "nbformat": 4, "nbformat_minor": 5 }