licom — drawing the lines of communication

See your Teams call flow, instantly

Connect to Teams via PowerShell, drop the exported JSON in here, and get an interactive diagram of every Auto Attendant, Call Queue and routing target.

Files are parsed locally in your browser — nothing is uploaded.

Step 1 · Run this in PowerShell

One script. Exports resource accounts, auto attendants, call queues, business-hour schedules, holidays, greetings, timers and per-user forwarding to teams-callflow.json.

# Run in PowerShell as a Teams admin — produces a single teams-callflow.json file
# with full call-flow detail: schedules, holidays, greetings, timers, user forwarding.
if (-not (Get-Module -ListAvailable -Name MicrosoftTeams)) {
  Install-Module MicrosoftTeams -Scope CurrentUser -Force
}
Import-Module MicrosoftTeams
Connect-MicrosoftTeams | Out-Null

Write-Host "Fetching Resource Accounts..."
$appsRaw = @(Get-CsOnlineApplicationInstance -ErrorAction SilentlyContinue)

Write-Host "Fetching Phone Number Assignments..."
$assignments = @()
try {
  $assignments = @(Get-CsPhoneNumberAssignment -ErrorAction Stop)
} catch {
  Write-Warning "Get-CsPhoneNumberAssignment failed: $($_.Exception.Message)"
}

# Build lookup using every identifier Teams may expose for the Resource Account.
$phoneByTarget = @{}
foreach ($pn in $assignments) {
  $num = $pn.TelephoneNumber
  if (-not $num) { continue }
  foreach ($candidate in @(
    $pn.AssignedPstnTargetId, $pn.AssignedPstnTargetName, $pn.PstnAssignmentId,
    $pn.Identity, $pn.TargetId, $pn.TargetName, $pn.UserPrincipalName
  )) {
    if ($candidate) { $phoneByTarget[$candidate.ToString().ToLower()] = $num }
  }
}

# Merge phone numbers back onto application instances so the visualizer sees them
$apps = $appsRaw | ForEach-Object {
  $obj = $_
  $resolved = $obj.PhoneNumber
  foreach ($candidate in @($obj.ObjectId,$obj.Identity,$obj.Id,$obj.UserPrincipalName,$obj.DisplayName)) {
    if ($resolved) { break }
    if ($candidate) {
      $key = $candidate.ToString().ToLower()
      if ($phoneByTarget.ContainsKey($key)) { $resolved = $phoneByTarget[$key] }
    }
  }
  $obj | Add-Member -NotePropertyName PhoneNumber -NotePropertyValue $resolved -Force -PassThru
}

Write-Host "Fetching Auto Attendants (with full call-flow detail)..."
$aas = @(Get-CsAutoAttendant -ErrorAction SilentlyContinue -WarningAction SilentlyContinue)

Write-Host "Fetching Call Queues (with full settings)..."
$cqs = @(Get-CsCallQueue -ErrorAction SilentlyContinue -WarningAction SilentlyContinue)

Write-Host "Fetching Schedules (business hours + holidays)..."
$schedules = @()
try {
  $schedules = @(Get-CsOnlineSchedule -ErrorAction Stop -WarningAction SilentlyContinue)
} catch {
  Write-Warning "Get-CsOnlineSchedule failed: $($_.Exception.Message)"
}

Write-Host "Fetching Holidays per Auto Attendant..."
# Get-CsAutoAttendantHolidays REQUIRES -Identity — iterate per AA so it
# never prompts. Holiday schedules are also exposed via Get-CsOnlineSchedule
# above, so this is best-effort enrichment only.
$holidays = @()
foreach ($aa in $aas) {
  if (-not $aa.Identity) { continue }
  try {
    $h = Get-CsAutoAttendantHolidays -Identity $aa.Identity -ErrorAction Stop -WarningAction SilentlyContinue
    if ($h) { $holidays += $h }
  } catch { continue }
}

Write-Host "Fetching Users + Calling Settings..."
$users = @(Get-CsOnlineUser -ResultSize 2000 -ErrorAction SilentlyContinue |
  Select-Object Identity,DisplayName,UserPrincipalName,LineUri,UsageLocation,
                Department,JobTitle,EnterpriseVoiceEnabled)

# ---- Resolve every callable target ID to a human-friendly entity ---------
# Teams stores menu options / overflow / timeout / operator targets as a
# CallableEntity { Type; Id }. The Id is usually a GUID (User or
# ApplicationEndpoint) or "tel:+32..." (ExternalPstn). We pre-resolve every
# GUID we know about so the visualizer can show the actual phone number,
# display name and UPN — never a raw GUID.
$entityResolutions = @()

foreach ($a in $apps) {
  $entityResolutions += [pscustomobject]@{
    Id                = ($a.ObjectId  | Out-String).Trim()
    Identity          = ($a.Identity  | Out-String).Trim()
    Kind              = "ApplicationInstance"
    DisplayName       = $a.DisplayName
    UserPrincipalName = $a.UserPrincipalName
    PhoneNumber       = $a.PhoneNumber
  }
}

foreach ($u in $users) {
  $entityResolutions += [pscustomobject]@{
    Id                = ($u.Identity | Out-String).Trim()
    Identity          = ($u.Identity | Out-String).Trim()
    Kind              = "User"
    DisplayName       = $u.DisplayName
    UserPrincipalName = $u.UserPrincipalName
    PhoneNumber       = ($u.LineUri  -replace '^tel:','' -replace ';.*$','')
    Department        = $u.Department
    JobTitle          = $u.JobTitle
  }
}

# Distribution lists / groups referenced by call queues — resolve via Graph
# fallback (best-effort; missing groups are skipped silently).
$groupIds = @()
foreach ($cq in $cqs) {
  if ($cq.DistributionLists) { $groupIds += @($cq.DistributionLists) }
}
$groupIds = $groupIds | Select-Object -Unique
foreach ($gid in $groupIds) {
  if (-not $gid) { continue }
  try {
    $g = Get-CsGroup -Identity $gid -ErrorAction Stop
    $entityResolutions += [pscustomobject]@{
      Id          = $gid
      Identity    = $gid
      Kind        = "Group"
      DisplayName = $g.DisplayName
    }
  } catch { }
}

# Per-user forwarding / simultaneous-ring / call group settings — non-interactive
$userCallSettings = @()
$total = $users.Count
$i = 0
foreach ($u in $users) {
  $i++
  $upn = $u.UserPrincipalName
  if ([string]::IsNullOrWhiteSpace($upn)) { continue }
  if ($upn -notmatch '@') { continue }   # skip anything that wouldn't be a valid identity
  if ($i % 25 -eq 0) { Write-Host "  ...calling settings $i / $total" }
  try {
    $s = Get-CsUserCallingSettings -Identity $upn -ErrorAction Stop -WarningAction SilentlyContinue -Confirm:$false
    if (-not $s) { continue }
    $userCallSettings += [pscustomobject]@{
      UserPrincipalName    = $upn
      Identity             = $u.Identity
      IsForwardingEnabled  = $s.IsForwardingEnabled
      ForwardingType       = $s.ForwardingType
      ForwardingTargetType = $s.ForwardingTargetType
      ForwardingTarget     = $s.ForwardingTarget
      IsUnansweredEnabled  = $s.IsUnansweredEnabled
      UnansweredDelay      = $s.UnansweredDelay
      UnansweredTargetType = $s.UnansweredTargetType
      UnansweredTarget     = $s.UnansweredTarget
      Delegates            = $s.Delegates
      CallGroupOrder       = $s.CallGroupOrder
      CallGroupTargets     = $s.CallGroupTargets
      SimRingTargetType    = $s.SimRingTargetType
      SimRingTarget        = $s.SimRingTarget
    }
  } catch {
    # silently skip users we can't read (guests, service accounts, no Teams licence)
    continue
  }
}

$export = [ordered]@{
  exportedAt             = (Get-Date).ToString("o")
  microsoftTeamsModule   = (Get-Module MicrosoftTeams).Version.ToString()
  applicationInstances   = $apps
  phoneNumberAssignments = $assignments
  autoAttendants         = $aas
  callQueues             = $cqs
  schedules              = $schedules
  holidays               = $holidays
  users                  = $users
  userCallingSettings    = $userCallSettings
  entityResolutions      = $entityResolutions
}

$export | ConvertTo-Json -Depth 20 | Out-File -Encoding utf8 teams-callflow.json
Write-Host "Done — upload teams-callflow.json to the visualizer."

Requires Teams admin rights. The file is created in your current working directory.

Step 2 · Upload teams-callflow.json

Drop the file in. It's parsed locally — nothing leaves your browser.

Inbound numbers

Resource accounts and PSTN entry points.

Routing logic

Menus, DTMF options, overflow & timeout actions.

Local-first

Your tenant data never leaves the browser.