| name | app-studio-build |
| description | Step-by-step orchestrator for building Domo App Studio apps with native KPI cards via community-domo-cli. Sequences app creation, pages, theme, hero metrics, native charts, filter cards, layout assembly, and navigation. CLI-first — no raw API calls. |
App Studio Build Orchestrator
Build a complete App Studio app with native cards, hero metrics, filters, and polished layout. Every operation uses community-domo-cli.
Delegate to these skills for details:
card-creation — card body schema, chart type index, reference files for specific chart overrides
Prerequisites
Before starting, you need:
- Dataset GUIDs for every card. Discover columns:
community-domo-cli --output json datasets schema $DATASET_ID > schema.json
- Page definitions — names, icons, which dataset powers each page
- Hero metric definitions — which columns, aggregations, comparison periods
- Chart selections — which
badge_* type per page (vary across pages)
Step 1: Create App + Pages
Create the app
community-domo-cli --output json -y app-studio create \
--body '{"title": "APP_TITLE", "description": "APP_DESCRIPTION"}' > app.json
APP_ID=$(python3 -c "import json; print(json.load(open('app.json'))['dataAppId'])")
LANDING_PAGE=$(python3 -c "import json; print(json.load(open('app.json'))['landingViewId'])")
Create additional pages
One call per page. The landing page already exists — rename it if needed.
python3 -c "
import json
app = json.load(open('app.json'))
owner_id = app['owners'][0]['id']
body = {
'owners': [{'id': owner_id, 'type': 'USER', 'displayName': None}],
'type': 'dataappview', 'title': 'PAGE_NAME', 'pageName': 'PAGE_NAME',
'locked': False, 'mobileEnabled': True, 'sharedViewPage': True, 'virtualPage': False
}
json.dump(body, open('view_body.json', 'w'))
"
community-domo-cli --output json -y app-studio create-view $APP_ID \
--body-file view_body.json > view_PAGE_NAME.json
PAGE_ID=$(python3 -c "import json; print(json.load(open('view_PAGE_NAME.json'))['view']['pageId'])")
Repeat for each page. Track all page IDs.
Step 2: Theme + Icon
Apply theme
community-domo-cli --output json app-studio get $APP_ID > app_full.json
Modify theme in the response. Key theme standards:
theme = app['theme']
for comp in theme.get('components', []):
comp['borderRadius'] = 0
comp['borderWidth'] = 0
comp['dropShadow'] = 'NONE'
comp['itemBorderRadius'] = 0
if isinstance(comp.get('padding'), dict):
comp['padding'] = {'left': 0, 'right': 0, 'top': 0, 'bottom': 0}
for tbl in theme.get('tables', []):
tbl['borderRadius'] = 0
for nb in theme.get('notebooks', []):
if 'borderRadius' in nb:
nb['borderRadius'] = 0
Write back:
community-domo-cli --output json -y app-studio update $APP_ID --body-file app_full.json
Upload custom icon (optional)
community-domo-cli --output json -y files upload --file-path icon.png > icon.json
DATA_FILE_ID=$(python3 -c "import json; print(json.load(open('icon.json'))['dataFileId'])")
Set iconDataFileId and navIconDataFileId to DATA_FILE_ID in the app update body.
Step 3: Hero Metrics
Heroes use badge_pop_multi_value. This is the most failure-prone card type — follow exactly.
Required subscriptions
{
"subscriptions": {
"big_number": {
"name": "big_number",
"columns": [{"column": "METRIC_COL", "aggregation": "SUM", "alias": "METRIC_NAME", "format": {"type": "abbreviated", "format": "#A"}}],
"filters": []
},
"main": {
"name": "main",
"columns": [
{"column": "DATE_COL", "mapping": "ITEM"},
{"column": "METRIC_COL", "mapping": "VALUE", "aggregation": "SUM"}
],
"filters": [],
"orderBy": [],
"groupBy": [{"column": "DATE_COL"}],
"fiscal": false, "projection": false, "distinct": false
}
}
}
Required chart config
{
"charts": {
"main": {
"component": "main",
"chartType": "badge_pop_multi_value",
"overrides": {},
"goal": null
}
}
}
Critical hero gotchas
| Rule | Why |
|---|
| 3-4 heroes per page, SINGLE ROW | Never 5+, never 2 rows |
"format": {"type": "abbreviated", "format": "#A"} only | Other format types (currency, percentage) cause 400 |
Do NOT include dateGrain | Conflicts with subscription structure |
Note: dateRangeFilter + time_period subscription (period-over-period comparison) causes HTTP 400 at the cards create endpoint. Omit both
until resolved. Heroes will render as single-value KPI metrics without YoY delta.
Create one card per hero metric:
community-domo-cli --output json -y cards create \
--body-file hero_metric.json --page-id $PAGE_ID > hero_card.json
Step 4: Native Charts
Each page gets a primary visualization (full-width) plus 2-6 detail cards. Vary chart types across pages — don't use the same type everywhere.
Chart selection strategy
| Data Pattern | Recommended Types |
|---|
| Trend over time | badge_two_trendline, badge_vert_area_overlay, badge_curvedline |
| Category comparison | badge_vert_bar, badge_horiz_bar, badge_vert_stackedbar |
| Part-to-whole | badge_donut, badge_vert_100pct, badge_treemap |
| Ranking | badge_horiz_bar, badge_funnel, badge_bump |
| Correlation / scatter | badge_xybubble, badge_xy_line |
| Tabular detail | badge_flex_table, badge_basic_table |
| Combo (bar + line) | badge_line_bar, badge_symbol_bar |
Create each card
- Build the card body per the
card-creation skill (Section 2: Body Schema), in the same skills directory as this file
- Pick
chartType from the chart type index (Section 5)
- Read the relevant reference file from
card-creation/references/ (in the same skills directory as this file). Key references: bar.md, line.md, pie.md, period-over-period.md, selector.md, table.md.
- Create via CLI:
community-domo-cli --output json -y cards create \
--body-file chart_card.json --page-id $PAGE_ID > chart_response.json
Primary viz: width 60 in layout. Detail cards: width 20 or 30 (2-3 per row).
Step 5: Filter Cards
Selectors that drive page-level filtering. Use badge_dropdown_selector for most filters.
Filter card body
{
"definition": {
"subscriptions": {
"big_number": {
"name": "big_number",
"columns": [{"column": "FILTER_COL", "aggregation": "COUNT"}],
"filters": []
},
"main": {
"name": "main",
"columns": [{"column": "FILTER_COL", "mapping": "ITEM"}],
"filters": [],
"groupBy": [{"column": "FILTER_COL"}],
"orderBy": [], "fiscal": false, "projection": false, "distinct": false
}
},
"formulas": {"dsUpdated": [], "dsDeleted": [], "card": []},
"annotations": {"new": [], "modified": [], "deleted": []},
"conditionalFormats": {"card": [], "datasource": []},
"controls": [],
"segments": {"active": [], "create": [], "update": [], "delete": []},
"charts": {
"main": {"component": "main", "chartType": "badge_dropdown_selector", "overrides": {}, "goal": null}
},
"dynamicTitle": {"text": [{"text": "FILTER_COL", "type": "TEXT"}]},
"dynamicDescription": {"text": [{"text": "", "type": "TEXT"}], "displayOnCardDetails": true},
"chartVersion": "12", "inputTable": false, "noDateRange": false,
"title": "FILTER_COL", "description": ""
},
"dataProvider": {"dataSourceId": "DATASET_UUID"},
"variables": true, "columns": false
}
CRITICAL: big_number.columns must NOT be empty for selectors — use COUNT on the filter column. Empty columns causes HTTP 400.
Create on first page, then share to other pages:
community-domo-cli --output json -y cards create \
--body-file filter.json --page-id $FIRST_PAGE > filter_resp.json
FILTER_ID=$(python3 -c "import json; print(json.load(open('filter_resp.json'))['id'])")
community-domo-cli -y pages add-card $OTHER_PAGE $FILTER_ID
Step 6: Layout Assembly
Choose a layout from the table below (default is the Hero Grid). Read the corresponding
layout reference file for full grid coordinates, card_positions, and special_entries.
Layout index
| Layout | File | isDynamic | Slots | Best for |
|---|
| Default: Hero Grid | layouts/layout-default-hero-grid.md | False | Filters + heroes + primary viz + detail row | Standard dashboard with KPI metrics |
| A: Right Sidebar | layouts/layout-a-right-sidebar.md | True | Wide left + narrow right, 3 sections with page breaks | Master-detail, commentary panels |
| B: Symmetric Grid | layouts/layout-b-symmetric-grid.md | False | Full→halves→thirds→6-grid→full, separator | Data-dense comparison dashboards |
| C: Left Column Feature | layouts/layout-c-left-column-feature.md | True | Narrow left stack + wide right, two-panel lower | KPI sidebar with feature visualization |
| D: Full Canvas | layouts/layout-d-full-canvas.md | True | Single edge-to-edge slot | Embedded app, map, or full-bleed viz |
| E: Left Filter + Form | layouts/layout-e-left-filter-form.md | False | Left filters + wide right, spacer, form + card, full bottom | Data entry / interactive forms |
Read the layout reference file before building. It contains the exact card_positions,
special_entries, and is_dynamic values to paste into layout-builder.py or the inline script below.
Critical: cards create --page-id automatically adds cards to the layout's content array
as appendix entries. Always run layout-get AFTER creating all cards so the content array is
populated. The Python builder below mutates those existing entries — it does not create new ones.
Running layout-get before card creation will result in an empty content array with nothing to position.
community-domo-cli --output json app-studio layout-get $APP_ID $PAGE_ID > layout.json
Default y-band grid pattern (Hero Grid)
| Band | y | height | Content |
|---|
| Banner | 0 | 14 | Page banner image (pro-code) or HEADER |
| Filters | 14 | 6 | Filter cards side-by-side |
| Heroes | 20 | 14 | 3-4 hero metrics in single row |
| Section header | 34 | 4 | HEADER text |
| Primary viz | 38 | 30 | Full-width chart (width 60) |
| Section header | 68 | 4 | HEADER text |
| Detail cards | 72 | 30 | 2-3 cards per row (width 20 or 30) |
Content entry types
| Type | Required Fields |
|---|
CARD | contentKey, type: "CARD", cardId, x, y, width, height, style: null, hideTitle: true/false, hideSummary: true, virtual: false, virtualAppendix: false |
HEADER | contentKey, type: "HEADER", text, x, y, width, height, virtual: false, virtualAppendix: false |
PAGE_BREAK | contentKey, type: "PAGE_BREAK", x, y, width, height: 0 — visual page boundary |
SEPARATOR | contentKey, type: "SEPARATOR", x, y, width, height — horizontal rule |
SPACER | contentKey, type: "SPACER", x, y, width, height — empty whitespace block |
FORM | contentKey, type: "FORM", x, y, width, height — embedded form region |
Card layout content entry
{
"contentKey": 100,
"type": "CARD",
"cardId": CARD_ID_INT,
"x": 0, "y": 38, "width": 60, "height": 30,
"style": null,
"hideTitle": false,
"hideSummary": true,
"hideBorder": false,
"hideMargins": false,
"fitToFrame": false,
"virtual": false,
"virtualAppendix": false,
"children": null
}
Hero card entry — additional flags:
{
"hideTitle": true, "hideSummary": true,
"hideBorder": true, "hideMargins": true, "fitToFrame": true,
"style": null, "height": 14
}
Filter card entry — low-profile:
{
"hideTitle": true, "hideSummary": true,
"hideBorder": true, "hideMargins": true, "fitToFrame": true,
"style": null, "height": 6
}
Build and apply layout
Shortcut: Copy layout-builder.py (in the same skill directory as this file) to your working
directory, fill in card_positions, header_positions, special_entries, and is_dynamic at the
top (copy these values from the layout reference file), and run:
python3 layout-builder.py --layout layout_PAGE.json --out layout_PAGE_updated.json
The full inline script below is the same logic — use it if you need to customize beyond positions.
import json, copy
layout = json.load(open('layout.json'))
card_to_key = {}
for c in layout['content']:
if c['type'] == 'CARD' and 'cardId' in c:
card_to_key[c['cardId']] = c['contentKey']
card_positions = {
}
header_positions = {
}
new_content = []
std_template = []
cmp_template = []
for entry in layout['standard']['template']:
if entry.get('contentKey', -1) == 0:
std_template.append({**entry, 'virtual': True, 'virtualAppendix': True})
for entry in layout['compact']['template']:
if entry.get('contentKey', -1) == 0:
cmp_template.append({**entry, 'virtual': True, 'virtualAppendix': True})
for c in layout['content']:
key = c['contentKey']
entry = copy.deepcopy(c)
if c['type'] == 'HEADER' and key in header_positions:
pos = header_positions[key]
entry.update({'text': pos['text'], 'x': pos['x'], 'y': pos['y'],
'width': pos['w'], 'height': pos['h'],
'virtual': False, 'virtualAppendix': False})
new_content.append(entry)
std_template.append({'contentKey': key, 'type': 'HEADER',
'x': pos['x'], 'y': pos['y'], 'width': pos['w'], 'height': pos['h'],
'virtual': False, 'virtualAppendix': False, 'children': None})
cmp_template.append({'contentKey': key, 'type': 'HEADER',
'x': pos['cx'], 'y': pos['cy'], 'width': pos['cw'], 'height': pos['ch'],
'virtual': False, 'virtualAppendix': False, 'children': None})
elif c['type'] == 'CARD' and c.get('cardId') in card_positions:
pos = card_positions[c['cardId']]
hero = pos.get('hero', False)
filt = pos.get('filt', False)
flags = {'hideTitle': hero or filt, 'hideSummary': True,
'hideBorder': hero or filt, 'hideMargins': hero or filt,
'fitToFrame': hero or filt}
entry.update({**flags, 'x': pos['x'], 'y': pos['y'],
'width': pos['w'], 'height': pos['h'],
'virtual': False, 'virtualAppendix': False})
new_content.append(entry)
std_template.append({**flags, 'contentKey': key, 'type': 'CARD',
'x': pos['x'], 'y': pos['y'], 'width': pos['w'], 'height': pos['h'],
'virtual': False, 'virtualAppendix': False, 'style': None, 'children': None})
cmp_template.append({**flags, 'contentKey': key, 'type': 'CARD',
'x': pos['cx'], 'y': pos['cy'], 'width': pos['cw'], 'height': pos['ch'],
'virtual': False, 'virtualAppendix': False, 'style': None, 'children': None})
else:
entry.update({'virtual': True, 'virtualAppendix': True})
new_content.append(entry)
std_template.append({'contentKey': key, 'type': c['type'],
'x': 0, 'y': 0, 'width': 15, 'height': 14,
'virtual': True, 'virtualAppendix': True, 'style': None, 'children': None})
cmp_template.append({'contentKey': key, 'type': c['type'],
'x': 0, 'y': 0, 'width': 6, 'height': 14,
'virtual': True, 'virtualAppendix': True, 'style': None, 'children': None})
special_entries = [
]
is_dynamic = False
if special_entries:
all_keys = [c.get('contentKey', 0) for c in new_content]
all_keys += [e.get('contentKey', 0) for e in std_template]
next_key = max(all_keys) + 100 if all_keys else 900
for se in special_entries:
ck = next_key
next_key += 1
base = {'contentKey': ck, 'type': se['type'],
'virtual': False, 'virtualAppendix': False, 'children': None}
new_content.append({**base, 'x': se['x'], 'y': se['y'],
'width': se['w'], 'height': se['h']})
std_template.append({**base, 'x': se['x'], 'y': se['y'],
'width': se['w'], 'height': se['h']})
cmp_template.append({**base, 'x': se['cx'], 'y': se['cy'],
'width': se['cw'], 'height': se['ch']})
layout['content'] = new_content
layout['standard']['template'] = std_template
layout['compact']['template'] = cmp_template
layout['isDynamic'] = is_dynamic
json.dump(layout, open('layout_updated.json', 'w'))
canvas = sum(1 for c in new_content if not c.get('virtualAppendix'))
appx = sum(1 for c in new_content if c.get('virtualAppendix'))
print(f'Layout written: {canvas} canvas, {appx} appendix, isDynamic={is_dynamic}')
Apply:
community-domo-cli --output json -y app-studio layout-set $APP_ID $PAGE_ID \
--body-file layout_updated.json
Repeat for every page.
Step 7: Navigation
Set LEFT orientation with custom icons on every page.
community-domo-cli --output json app-studio get $APP_ID > app_nav.json
import json
app = json.load(open('app_nav.json'))
app['navOrientation'] = 'LEFT'
app['showDomoNavigation'] = False
app['showTitle'] = False
app['showLogo'] = False
json.dump(app, open('app_nav_updated.json', 'w'))
community-domo-cli --output json -y app-studio update $APP_ID --body-file app_nav_updated.json
Note: navOrientation, showDomoNavigation, showTitle, showLogo update correctly via
app-studio update. Nav entry title and icon fields are always silently ignored by this
API — do not set them. Page nav labels come from pageName set at create-view time.
Verified Domo-native icon names
Google Material icon names DO NOT WORK. Use only these:
| Category | Icons |
|---|
| Home | home |
| Dashboard | analytics, pop-chart, chart-bar-vertical, select-chart, badge-layout-8 |
| Operations | gauge, dataflow, cube-filled, completed-submissions |
| Quality | certified, checkbox-marked-outline, check-in-icon, approval-center |
| Logistics | globe, data-app, local_shipping, warehouse, shopping_cart |
| Retail | store, cube-filled, numbers, toolbox |
| Financial | money-universal, money, benchmark, books, calculator |
| People | people, person, person-card, person-plus |
| Time | clock, calendar-simple, calendar-time, alarm |
| AI | ai-chat, magic, wand, lightbulb, lightning-bolt |
| Settings | controls, pages-gear, code-tags, pencil-box |
Step 8: Verification
After all pages are complete:
- Check card counts:
for PAGE in $LANDING_PAGE $PAGE2 $PAGE3 $PAGE4; do
echo "Page $PAGE:"
community-domo-cli --output json pages list-cards $PAGE | python3 -c "
import json,sys
data=json.load(sys.stdin)
cards=data.get('cards', [])
print(f' {len(cards)} cards')
for c in cards: print(f' - {c[\"id\"]}: {c.get(\"title\",\"untitled\")}')
"
done
- Report to user:
- App URL:
https://INSTANCE.domo.com/app-studio/APP_ID
- App ID and all page IDs
- Card count per page
- Any cards that failed creation (with error details)
Global UI/UX Standards (Mandatory)
These apply to EVERY App Studio app built with this skill:
| Standard | Value |
|---|
borderRadius | 0 everywhere — cards, tables, notebooks, components, buttons, tabs |
borderWidth | 0 on all cards |
dropShadow | NONE on all cards |
padding | 0 on all cards |
isDynamic | false — fixed-width layout |
density | {compact: 8, standard: 8} |
| Controls color c8 | #2563BE |
| Heroes | SINGLE ROW, height 14, max 4, hideTitle/hideSummary/hideBorder/hideMargins/fitToFrame all true |
| Filters | Height 6, style: null, all hide flags true |
| Never duplicate apps | On retry, reuse existing appId |
navOrientation | LEFT |
showDomoNavigation | false when nav is LEFT |