---
name: cse-account-assignment
description: Build or refresh the CSE coverage / account-assignment map. Resolves who covers each account (standing TAM vs engaged CSE) against the live Jira board, enriches every account with live Salesforce ARR and open pipeline, balances accounts that need new coverage across the team by heavy-hitter score, and writes it all to the coverage Google Sheet. Use when the user asks to assign CSEs to accounts, build or populate the CSE coverage / assignment sheet, figure out who covers what, balance the book, add accounts a CSE is engaged with, or split TAM and CSE coverage into columns. Builds on the cse-profile-builder scores.
argument-hint: "[coverage sheet URL or tab | 'refresh' | account list]"
---

# CSE Account Assignment

## Compact MCP routing

Follow the shared [compact MCP routing contract](../../shared/compact-mcp-routing.md). Interactive facade tools are `cse_capabilities`, `cse_read`, `cse_apply`, `context_assemble`, and `cse_session_info`; named operations are capability ids. Call reads through `cse_read` with the capability id. Every mutation, including Sheets mutations, goes through `cse_apply` twice: dry-run first, then the identical capability and arguments with `execute:true`, justification (>=16 chars), and the returned `preview_digest`. Call `context_assemble` and `cse_session_info` directly when needed.


Produce a coverage map: one row per account carrying its standing TAM, its
engaged or assigned CSE, and live Salesforce enrichment, balanced by the
comparative heavy-hitter score and written to the coverage Google Sheet. This is
the assignment layer on top of
[cse-profile-builder](../cse-profile-builder/SKILL.md), which produces the
scores. It treats the live Jira CSE board as the source of truth for who is
engaged.

## Coverage model (read first)

The TAM/CSE split encodes three coverage states:

- **No TAM.** The CSE is the primary coverage. The CSE name is the coverage.
- **TAM present and a CSE already engaged.** Dual coverage: the TAM is standing
  coverage and the engaged CSE goes in the CSE column too. Never pull an engaged
  CSE off an account.
- **TAM present and no engaged CSE.** TAM-only. Do not assign a new CSE to a
  TAM-covered account on this sheet.

Source-of-truth split: the Jira CSE board owns who is engaged (the engagement
assignee), and Salesforce / Kepler owns account metadata and the TAM. Slack is
never authoritative for ownership.

## Domain configuration

The daemon resolves the Jira cloud ID, project key, and issue type; use `jira_*` capability reads with semantic names. Use the `cse_domain_info` capability for project/stage context. Two semantic fields tie an account to its engagement: `customer_name` and `team_id` (Salesforce Team_ID). The daemon maps them to customfield ids.

## MCP setup

```bash
source "${PLUGIN_ROOT:-${DROID_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-$PWD/plugins/cse-tools}}}/.agents/shared/skill-bootstrap.sh"
```

This resolves `CSE_TOOLD_BIN` and `CSE_OPERATOR`; the daemon keeps keychain auth fresh on its own, so no mint step is needed. Salesforce account data comes from `get_salesforce_account` / `list_customers`
(Kepler mirror); there is no direct Salesforce CLI or REST path in this skill.
On 401, call `cse_session_info({"force_refresh":true})` once and retry. On `403
legacy_key_scope_required`, do not refresh or retry: stop and tell the operator to
request/reissue a Kepler key with `kepler:accounts:read`. Do not query Salesforce directly. Jira and Google Sheets go through the
local cse-tools MCP surface.

- **Read strategy:** Follow [`.agents/shared/read-strategy.md`](../../shared/read-strategy.md). Coverage truth comes directly from the live Jira board (`jira_search` capability), Salesforce (`list_customers` / `get_salesforce_account` capabilities), and saved profile scores. `context_assemble({"entity":{"kind":"account","ref":"<ambiguous-name>"}})` is allowed only as an optional account-name ambiguity resolver; it is never the source for a coverage row, owner, TAM, Team_ID, ARR, pipeline, or score.
- **Tool frugality:** Follow [`.agents/shared/tool-frugality.md`](../../shared/tool-frugality.md). Read only the Sheet ranges, Jira fields, and Salesforce records needed for target rows. Use compact responses by default and do not hydrate unrelated communications during name resolution.

## Step 1: scores (reuse cse-profile-builder)

Read the per-CSE profiles at `.work-archive/account-assignment/<first>-<last>.yaml`.
Before balancing, require `schema_version: 1`, `rubric_version: 1`, a parseable
`generated_at` no older than 7 days, the structural fields/typing defined by
cse-profile-builder, and its minimum source coverage. Null scores, `Unknown` tier,
missing source statuses, or stale/invalid profiles block balancing; refresh them
first. The score rubric and tiers live in `_profile-spec.md`; do not recompute them.
Build the eligible roster independently with
`cse_orgchart_identity_snapshot` and arguments `{"refresh":true}`, with the same configured CSE
roots as profile-builder. Match profiles by work email, reject saved profiles absent
from the live roster as leavers/missing members, and require every live member to
have a valid profile before assignment.

## Step 2: account book

Get the target accounts. When working a Google Sheet, read it first through
`google_sheets_metadata`, then `google_sheets_batch_values` to capture the exact tab
name, the header layout (which coverage columns exist and in what order), and
which rows are already populated. The header layout varies per tab and gets
edited live, so re-read it before every write rather than trusting a prior read.
Pin the destination spreadsheet ID from the operator-provided URL/config before any
preview. Confirm metadata matches that ID and record the approved sharing boundary
(named internal domain/group); stop if either is absent or broader sharing is shown.

## Step 3: coverage resolution (fan-out)

Per account:

1. Resolve the Salesforce Team_ID. `list_customers(search=...)` returns the
   `crm_id` (names with apostrophes or hyphens often miss; retry with a
   partial token). A partial-token result is accepted only when normalized account
   name equality (case-folded, punctuation/whitespace collapsed) yields exactly one
   match; otherwise require explicit operator confirmation of the CRM ID. Then
   `get_salesforce_account` with arguments `{"account_id":"<crm_id>"}` returns
   the `team_id` and the `roster[]`, which now carries the **TAM** alongside
   owner/CSM/SE/ADR.
2. Engaged CSE. `jira_search` with `named_query: "all_open"` and semantic
   `fields: ["team_id","customer_name",...]`, matching the account on `team_id`
   (most reliable) or `customer_name`. An engagement that is not in
   Closed/Declined/Abandoned/Redirected means the **assignee is the engaged CSE**.
   Confirm the ticket is a real CSE Engagement; an account-manager or TAM ticket
   on the same customer does not count.
3. TAM. Take it from the Salesforce roster `tam` role. Leave blank if none.

## Step 4: enrichment (fan-out)

Per account pull the Salesforce fields for the sheet:

- Current ARR = `Total_Postman_Team_ARRDLRS__c`
- open pipeline = `Open_Pipeline_ARR__c`
- segment = `Segment__c`, industry = `Industry`, tier = `Account_Tier__c`
- licenses = `Total_Paid_Licenses__c`, MAU = `MAU_Account__c`
- AE = `Owner.Name`, owner role = `Account_Owner_Role__c`

Derived columns and the traps that bite:

- **Total $ Open = Current ARR + Open_Pipeline_ARR__c.** This is the verified
  sheet formula: the sum of both fields, which the sheet's own rows confirm.
  Watch for the common mistake of writing the open pipeline alone.
- **Strategic Tier** renders `Tier {N} - {Segment}`. `Tier 1 - StratOps` is a
  Revenue-Bets-only label; do not stamp it on Enterprise or Mid-Market accounts.
- **License %** and **Renewal Date** are not Account fields (an external
  enrichment pass produced them). Leave blank unless sourced from a renewal
  Opportunity CloseDate or a defined utilization ratio. Do not guess them.
- A `Total_Paid_Licenses` of 0 on an account with real ARR usually means
  enterprise or other licensing; leave Licenses blank rather than writing `0`.

## Step 5: balance accounts that need new coverage

Only accounts with no TAM and no engaged CSE need a new assignment. Apply this
deterministic lexicographic order: (1) existing account incumbency, (2) count of
evidence-backed matching vertical tags, (3) remaining numeric capacity, (4) higher
profile score, then (5) normalized work email ascending. Capacity band means 0-4
effective open engagements eligible, 5-6 full (no new assignment), and 7+ overloaded;
incumbent continuity may exceed the band but must be flagged. `effective open` is the
validated profile Jira open count plus assignments made in this run. A `marquee`
account is Strategic Tier 1 or current ARR >= $1M. A `hardest build` is one with at
least two documented complexity signals (multi-region, regulated/IAM, migration,
custom integration) and no incumbent. Process hardest builds first, then marquee,
then remaining accounts, sorting within each group by current ARR descending and
normalized account name ascending. Inputs are the
heavy-hitter score, tier, and open-engagement headroom from Step 1, the ARR and
Tier mix from Step 4, and the body-of-work verticals. Keep ramping CSEs on their
strengths and their incumbents; give the most headroom and the top score the
hardest builds. Engaged CSEs and TAM-only accounts are fixed. Do not rebalance
them.

## Step 6: write the sheet

Columns: TAM (standing), CSE (engaged or assigned), plus the enrichment set
(Current ARR, Total $ Open, AE, account_owner_role, Strategic Tier, Industry,
Licenses, MAU, and a one-line Signals string). Write discipline, all of which
this skill learned the hard way:

- Before the first mutation preview, read the full affected A1 range and save the
  exact values plus spreadsheet ID, tab, range, and timestamp to
  `.work-archive/account-assignment/backups/<UTC-timestamp>.json`. Abort if backup
  creation or verification fails.
- Every write is **dry-run first**. Carry the returned `preview_digest` into the
  `execute` call with a `justification`. The sheet is often edited live, so a
  digest can go stale and `execute` rejects it. Re-run the dry-run and use the
  fresh digest.
- `google_sheets_batch_update_values` has one request-wide `value_input_option`.
  Split writes into separate guarded preview/digest/execute batches: `RAW` only for
  identity and literal-name ranges, and `USER_ENTERED` only for currency/number
  ranges. Never mix both cell classes in one request.
- Trailing empty cells in a write range get trimmed by the API. To clear a cell,
  end the range on a non-empty cell or use `clear_values`.
- **Re-read the true last data row before appending** new account rows. Data may
  already exist below where the visible block ends; appending blind overwrites it.
- Never overwrite a populated cell without surfacing it first. Never fabricate:
  leave a cell blank and flag it (no SFDC match, License %, Renewal Date) instead
  of guessing.

## Orchestration

Steps 3 and 4 are the fan-out: one subagent per account (or per ~10-account
chunk) that pulls the Jira engagement and the Salesforce record in one context
and returns a resolved coverage row. Run them in parallel (one Agent message,
batched). This is naturally a Workflow
`pipeline(ACCOUNTS, resolve_coverage, enrich)` with a **barrier** before Step 5
(the balance pass needs every account's score, ARR, and headroom together), then
the main loop runs Step 5 and the gated Step 6 writes. Steps 1, 5, and 6 stay on
the main loop.

## Voice / guard note

Sheet writes go through the daemon's guarded `google_sheets_*` tools (dry-run and
digest), not the Edit hook. The coverage sheet is internal planning data; run any
profile refresh with `CSE_TOOLS_VOICE_EDIT_ENFORCE=warn` as cse-profile-builder
documents. Outbound Slack, Jira, and Confluence prose stays fully enforced.
