Skip to content

Module 4 — Send (Apps Script) and the full flow

Office Hours: Session 15 — Thursday, 17 December 2026

Session goal: a custom step that takes inputs from earlier steps, restores the links, formats the email, and sends it — then wire the whole flow.

Bring: Modules 1–3 done.

What this step does

  • Takes two inputs: the Writer Checker's briefing and linkMapJson from CoS Gather.
  • Strips whatever markdown the models produced, then lays out every section the same way: colored RACI label, bold title, link, one-line summary.
  • Turns every ref into its real link; flags any ref it can't find instead of making a broken link.
  • Removes the Checker notes (they stay visible in Studio's Activity tab) and emails the briefing to whoever runs the flow.

Part 1 — Create the project

  1. On script.google.com, click New project and rename it CoS Send.
  2. Project Settings → check "Show 'appsscript.json' manifest file in editor."
  3. Editor → Code.gs: select all, delete, paste the Code.gs block below.
  4. appsscript.json: select all, delete, paste the manifest block below.
  5. Save. Check for a red error.
  6. Deploy → Test deployments → Install → Done. Reload the page if Install doesn't show.

Keep it a separate project from CoS Gather: each manifest lists only the step whose code is in that project.

Part 2 — Finish the flow

  1. Refresh Workspace Studio.
  2. Add CoS Send after the Writer Checker step.
  3. In its card, use the variable picker to set Briefing to the Writer Checker's response and Link map to linkMapJson from CoS Gather.
  4. Click Turn on and run it. The briefing lands in your inbox.
  5. When it works, switch the starter from Start manually to a weekday-morning schedule.

In the session

  • Live debug: "Script function not found" — a manifest listing a step whose code isn't in that project.
  • Live debug: titles not bold because the model added bullet markers — why Send normalizes the text before laying it out.
  • Stretch: change the label colors or add a footer line to the email.

Code — appsscript.json

{
  "timeZone": "America/New_York",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "addOns": {
    "common": {
      "name": "CoS Send",
      "logoUrl": "https://www.gstatic.com/images/branding/productlogos/buganizer/v1/192px.svg",
      "useLocaleFromApp": true
    },
    "studio": {
      "flows": {
        "workflowElements": [
          {
            "id": "cosSend",
            "state": "ACTIVE",
            "name": "CoS Send",
            "description": "Turns refs into links and emails the CoS Briefing to you.",
            "workflowAction": {
              "inputs": [
                { "id": "briefingText", "description": "Briefing text from the Writer Checker step", "cardinality": "SINGLE", "dataType": { "basicType": "STRING" } },
                { "id": "linkMapJson", "description": "Link map from CoS Gather", "cardinality": "SINGLE", "dataType": { "basicType": "STRING" } }
              ],
              "outputs": [
                { "id": "sendStatus", "description": "What was sent and any unknown refs", "cardinality": "SINGLE", "dataType": { "basicType": "STRING" } }
              ],
              "onConfigFunction": "onConfigSend",
              "onExecuteFunction": "onExecuteSend"
            }
          }
        ]
      }
    }
  }
}

Code — Code.gs

function onConfigSend() {
  const vars = () => CardService.newHostAppDataSource()
    .setWorkflowDataSource(CardService.newWorkflowDataSource().setIncludeVariables(true));
  const section = CardService.newCardSection()
    .setHeader('CoS Send')
    .addWidget(CardService.newTextInput().setFieldName('briefingText').setTitle('Briefing (Writer Checker output)')
      .setHostAppDataSource(vars()))
    .addWidget(CardService.newTextInput().setFieldName('linkMapJson').setTitle('Link map (from CoS Gather)')
      .setHostAppDataSource(vars()));
  return CardService.newCardBuilder().addSection(section).build();
}

function onExecuteSend(event) {
  const inputs = (event && event.workflow && event.workflow.actionInvocation && event.workflow.actionInvocation.inputs) || {};
  const text = stepInput_(inputs, 'briefingText');
  let linkMap = {};
  try { linkMap = JSON.parse(stepInput_(inputs, 'linkMapJson') || '{}'); } catch (e) { linkMap = {}; }
  const status = sendBriefing(text, linkMap);
  const action = AddOnsResponseService.newReturnOutputVariablesAction().setVariableDataMap({
    sendStatus: AddOnsResponseService.newVariableData().addStringValue(status),
  });
  return AddOnsResponseService.newRenderActionBuilder()
    .setHostAppAction(AddOnsResponseService.newHostAppAction().setWorkflowAction(action))
    .build();
}

function stepInput_(inputs, name) {
  const v = inputs[name];
  if (!v) return '';
  if (v.stringValues && v.stringValues.length) return v.stringValues[0];
  if (typeof v.value === 'string') return v.value;
  return typeof v === 'string' ? v : '';
}

function sendBriefing(text, linkMap) {
  const me = Session.getActiveUser().getEmail();          // whoever runs the flow
  const tz = CalendarApp.getDefaultCalendar().getTimeZone(); // that person's own time zone
  const name = me.split('@')[0].split(/[._-]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
  const subject = `${name}'s CoS Briefing – ${Utilities.formatDate(new Date(), tz, 'EEEE, MMMM d, yyyy')}`;
  const unknown = [];

  // Studio turns domain names into google.com redirect links; strip all links back to plain text
  const clean = String(text || '')
    .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1')
    .replace(/https?:\/\/www\.google\.com\/url\?\S+/g, '')
    .replace(/\n\s*Checker notes\b[\s\S]*$/i, ''); // checker notes stay in Studio Activity, not the email

  const HEADING = /^(Daily Summary|Upcoming Deliverables|Unmapped Operational Red Flags|Upcoming Calendar|Weekly Retrospective|Closing Notes)\b/i;
  const SUBHEAD = /^\[(RESPONSIBLE\/ACCOUNTABLE|CONSULTED\/INFORMED)\]/;
  const TAGLINE = /^\[(RESPONSIBLE|ACCOUNTABLE|CONSULTED|INFORMED)\]\s*/;
  const TAG_COLOR = { RESPONSIBLE: '#1a73e8', ACCOUNTABLE: '#188038', CONSULTED: '#e37400', INFORMED: '#5f6368' };
  const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  const fmtInline = s => esc(s).replace(/\[([EMNPRDT]{1,2}\d+)\]/g, (m, ref) => {
    if (linkMap[ref]) return `<a href="${linkMap[ref]}" style="color:#1a73e8">[${ref}]</a>`;
    unknown.push(ref);
    return `[${ref}?]`;
  });

  // Normalize whatever markdown the models used, then lay out every section the same way:
  // section heading > (optional group) > bold item line with a colored tag > plain one-line summary
  const html = clean.split('\n').map(raw => {
    const line = raw.trim()
      .replace(/^#+\s*/, '')          // markdown headings
      .replace(/^[*\-•]\s+/, '')      // bullet markers
      .replace(/\*\*|__/g, '')        // bold markers
      .replace(/^\*(.+)\*$/, '$1')    // a fully italic line
      .replace(/^Deeper Dive:\s*/i, '')
      .trim();
    if (!line) return '';
    if (SUBHEAD.test(line)) {
      return `<div style="margin:14px 0 4px;font-size:13px;font-weight:bold;color:#5f6368;text-transform:uppercase;letter-spacing:.3px">${fmtInline(line.replace(/^\[[^\]]+\]\s*—?\s*/, ''))}</div>`;
    }
    const tag = line.match(TAGLINE);
    if (tag) {
      const t = tag[1];
      return `<div style="margin:12px 0 2px"><span style="display:inline-block;min-width:96px;font-size:11px;font-weight:bold;color:#fff;background:${TAG_COLOR[t]};border-radius:3px;padding:1px 6px;margin-right:8px;text-align:center">${t}</span><b>${fmtInline(line.replace(TAGLINE, ''))}</b></div>`;
    }
    if (HEADING.test(line)) {
      return `<h3 style="margin:24px 0 6px;padding-bottom:4px;border-bottom:2px solid #dadce0;font-size:16px">${fmtInline(line.replace(/\s*\(.*\)\s*$/, ''))}</h3>`;
    }
    return `<div style="margin:0 0 4px 112px;color:#3c4043">${fmtInline(line)}</div>`;
  }).join('\n');

  const note = unknown.length
    ? `<hr><p style="color:#a00">Unknown refs (no link): ${[...new Set(unknown)].join(', ')}</p>` : '';
  GmailApp.sendEmail(me, subject, clean, { htmlBody: `<div style="font-family:Arial,sans-serif;font-size:14px;line-height:1.45;max-width:760px">${html}${note}</div>` });
  return `Sent "${subject}" to ${me}. Unknown refs: ${unknown.length ? [...new Set(unknown)].join(', ') : 'none'}`;
}

Changing things later

  • Edited code? Just save — test deployments run the latest saved code.
  • New permissions needed? Deploy → Test deployments → Uninstall, then Install.

Troubleshooting

What you see What to do
No Install button under Test deployments Reload the browser page; check appsscript.json saved without a red error.
Studio: "Something went wrong with the authentication…" Deploy → Test deployments → Uninstall, then Install. Refresh Studio.
Studio: "Script function not found" A manifest lists a step whose code isn't in that project. Re-paste both files for that project from this page.
Turn on is greyed out A step isn't fully set up — look for the step with a warning icon (often a missing variable).
Analyst says "No CoS data provided" The prompt has typed text instead of the gatherJson variable. Re-insert it with the variable picker.
A step doesn't appear in Studio Refresh Studio; confirm the test deployment is installed in that project.

Build kit home · 1 Gather · 2 Analyst · 3 Writer · 4 Send · 5 Build your own