Chapter 3
Chapter 3 . Agents and workflows . Jira test-plan agent

Jira to test plan: the model writes, the code formats

Type a Jira key and get a formal 13-section test plan. A small Express proxy fetches the ticket, asks Groq for the plan as JSON, and renders the Markdown with plain code, so the layout never depends on the model. The chapter also teaches B.L.A.S.T., the build protocol an AI coding agent followed to make the app.

13
Test plan sections
Objective to Approvals, defined in architecture/test-plan-template.md.
3
Layer 3 tools
jiraClient.js, groqClient.js and testPlan.js; server.js only routes.
5
B.L.A.S.T. phases
Blueprint, Link, Architect, Stylize, Trigger, after a Protocol 0 that creates the memory files.
200
Recorded live run
VWO-48 in progress.md: HTTP 200, a 13-section plan, 2293 bytes of Markdown.

01Why a tester cares

Most test plans start from a Jira ticket, and most of the writing repeats: objective, scope, environments, entry and exit criteria, risks, sign-off. This chapter builds a small app that drafts that plan from the ticket itself. You type a key such as VWO-48, the app fetches the issue, asks a model for the plan as JSON, and shows a formal 13-section plan that you can review and download as Markdown.

Three design choices make it usable at work, and they are the real lessons of the chapter:

  • The browser never talks to Jira. Jira Cloud's REST API does not allow browser calls (CORS), and an API token must not sit in front-end code, so a small Express proxy makes both outside calls.
  • The model writes content, code writes the document. Groq returns one JSON object with fixed keys. Plain JavaScript turns it into Markdown, so the headings, tables and file name are the same on every run.
  • Missing facts become TBD. The prompt forbids invented names, dates and versions, and the code fills any missing key with [] or "TBD", so a partial reply never breaks the page.
sequenceDiagram
  participant UI as React UI (port 5173)
  participant S as Express proxy (port 8787)
  participant J as Jira Cloud REST v3
  participant G as Groq openai/gpt-oss-120b
  UI->>S: POST /api/generate with jiraId and config
  S->>S: mergeConfig, non-empty UI values win over .env
  S->>J: GET /rest/api/3/issue/KEY?fields=...
  J-->>S: issue JSON, description in ADF
  S->>S: normalizeIssue, then buildMessages
  S->>G: chat/completions, JSON mode, temperature 0.3
  G-->>S: test plan content as JSON
  S->>S: generateTestPlan defaults, renderMarkdown
  S-->>UI: issue, plan and markdown
One Generate click. Only the proxy holds credentials; the model only ever sees the prompt and returns JSON.

02The B.L.A.S.T. framework

B.L.A.S.T.md is a master system prompt for an AI coding agent. You hand it to the agent together with your objective (Objective.md), and it makes the agent work in a fixed order instead of jumping straight to code: set up memory files, ask questions, prove every connection, then build in layers. The name is the five phases: Blueprint, Link, Architect, Stylize, Trigger.

PhaseWhat B.L.A.S.T.md asks forWhat it produced in this chapter
Protocol 0
Initialization
Create task_plan.md, findings.md, progress.md and a project constitution (schemas, rules, invariants). Write nothing in tools/ until the discovery questions are answered and the schema is approved.The four memory files. The constitution is LLM.md (the template calls it gemini.md).
B: BlueprintAsk five discovery questions: North Star, Integrations, Source of Truth, Delivery Payload, Behavioral Rules. Define the JSON input and output shapes before any code. Research.Four confirmed schemas in LLM.md: config, generate request, normalized issue, test-plan payload.
L: LinkTest every API connection and .env credential with minimal handshake scripts. Do not build logic on a broken link.tools/handshake.js: fetch one Jira issue, ask Groq for {"ok":true}, exit 0 or 1.
A: ArchitectBuild in three layers: SOPs in architecture/, a navigation layer that only routes, atomic tools in tools/.architecture/*.md, server.js, tools/jiraClient.js, groqClient.js, testPlan.js.
S: StylizeFormat the payload for delivery, build a clean UI, show the result to the user for feedback before deploying.React Generate and Settings tabs, a formatted view and a raw Markdown view, Download .md.
T: TriggerMove the logic to the cloud, set up triggers, finish the maintenance log.Vercel serverless functions in api/; a dated maintenance log at the end of LLM.md.
flowchart LR
  P0["Protocol 0"] --> B[Blueprint] --> L[Link] --> A[Architect] --> S[Stylize] --> T[Trigger]
  A -. a tool fails .-> R[Self-annealing] -.-> A
Protocol 0 first, then the five phases in order. When a tool fails, the self-annealing loop patches it and writes the lesson into its SOP.

The A.N.T. three-layer architecture

The Architect phase splits the build into three layers, because, in the protocol's words, LLMs are probabilistic and business logic must be deterministic. Layer 1 is Markdown that says how each tool behaves (the golden rule: change the SOP before the code). Layer 2 only routes. Layer 3 holds small tools you can test on their own.

The A.N.T. three-layer architecture of the chapter 3 app The React UI calls server.js, the navigation layer. server.js calls three tools in tools/. Markdown SOPs in architecture/ describe each tool. Only the tools talk to Jira and Groq. React UI (src/) POST /api/generate Layer 2: Navigation server.js routes, nothing else Layer 1: Architecture jira-fetch.md groq-generate.md test-plan-template.md SOP first, code second Layer 3: Tools (atomic, deterministic) jiraClient.js fetch + ADF groqClient.js JSON mode testPlan.js prompt, render Jira Cloud REST Groq API
The three layers as folders in the chapter. Only Layer 3 calls Jira and Groq.

Three operating rules

  • Data first. Write the input and output JSON shapes into the constitution before building any tool. Here that is LLM.md, section 3, with four confirmed schemas.
  • Self-annealing. When a tool fails: read the error, patch the tool, test it, then record the lesson in the matching architecture/*.md. The real example from progress.md: the .env said JIRA_API_TOKEN but the code read JIRA_TOKEN, so every reader now accepts both.
  • Deliverables versus intermediates. Scratch files go in .tmp/; the project is only done when the payload reaches its destination.
Read the template with this build in mind. B.L.A.S.T.md is generic: it says Layer 3 tools are Python scripts and the constitution is gemini.md. This build used JavaScript and renamed the file LLM.md. The phases and rules are the same.
Objective.md
# Fetch the JIRA ID and Create a Test Plan Generator
# VWO-48 -> Fetch Test Plan

You please read the file of B.L.A.S.T.md again and my objective again, and create a lightweight React application which will take:
- the Jira configuration
- Jira email ID
- Jira token
- my Jira base URL
- GROQ connection API details in the settings and take the JIRA ID and create the TestPlan automatically. 
You will be able to create a test plan based on the by fetching the vwo48 automatically. @chapter_03_BLAST_FW/B.L.A.S.T.md  

GROQ - openai/gpt-oss-120b (FREE)
fETCH JIRA -> emAIL, token, JIRA - VWO-48

The build brief, exactly as it was typed. prompt.md records the eight prompts that followed it.

03Live demo: one Generate click, step by step

Each press of Step runs one function of the real request path. Pick a ticket, choose where the credentials come from, and choose what the model sends back. VWO-48 and VWO-49 are the Jira responses stored as fixtures in chapter 13; VWO-50 is a bare ticket written for this page. The prompt and all the formatting code are ported from the repo; the model replies are canned, so every run is the same.

One Generate click, step by stepNo API key needed
POST /api/generate mergeConfig fetchIssue normalizeIssue buildMessages groqChat generateTestPlan renderMarkdown
data-testid=jt-ticketdata-testid=jt-credsdata-testid=jt-replydata-testid=jt-stepdata-testid=jt-rundata-testid=jt-resetdata-testid=jt-statusdata-testid=jt-plandata-testid=jt-viewdata-testid=jt-download
Server log
    This step
    
          Messages sent to Groq (ticket values highlighted)
          
    
        
    No plan yet
    Try these. Set Credentials to Nothing set: the proxy answers with the same 500 that progress.md recorded before the keys were added. Pick VWO-50 to see an empty description turn into (none provided) and the plan fill with TBD. Choose the reply JSON with 3 keys missing (no schedule, risks or approvals) and watch sections 7, 12 and 13. .env without GROQ key fails one step later, after Jira has already answered.

    04The code, file by file

    Everything that runs, and where it lives in chapter_03_BLAST_FW_JIRA_AI_AGENT.

    PieceWhat it doesRun or open
    npm run devStarts node server.js (SERVER, port 8787) and vite (CLIENT, port 5173) together; Vite proxies /api to 8787.npm run dev, then http://localhost:5173
    npm run handshakeThe Link phase check: fetches one Jira issue (default VWO-48) and asks Groq for {"ok":true}. Exit code 0 when both pass.npm run handshake VWO-48
    npm run build + npm startVite builds dist/; Express serves it and the API on one port with NODE_ENV=production.http://localhost:8787
    server.jsLayer 2. GET /api/config (only whether secrets exist), POST /api/generate, POST /api/save (writes output/test-plan-<id>.md).started by npm run dev
    tools/jiraClient.jsfetchIssue (Basic auth, field filter), flattenAdf, normalizeIssue.called by the routes
    tools/groqClient.jsgroqChat: Groq's OpenAI-compatible endpoint, JSON mode, temperature.called by testPlan.js
    tools/testPlan.jsSCHEMA_HINT, buildMessages, generateTestPlan (defaults), renderMarkdown (13 sections).Solution tab
    api/*.js + vercel.jsonThe same three routes as Vercel serverless functions. save returns 501.npx vercel deploy --prod
    src/React: App.jsx (tabs, localStorage), Generator.jsx, Settings.jsx, TestPlanView.jsx, lib/api.js.the UI on port 5173
    architecture/*.mdLayer 1 SOPs: jira-fetch, groq-generate, test-plan-template (the 13 sections).read before changing a tool
    task_plan.md, findings.md, progress.md, LLM.md, prompt.mdProject memory: checklists, research, a dated progress log, the constitution, and the 8 prompts that drove the build.read in that order

    Fetch and flatten the ticket

    Jira REST v3 returns the description as Atlassian Document Format (ADF), a tree of nodes rather than text. flattenAdf walks the tree, keeps the text and adds a line break after every block, then normalizeIssue turns the response into the flat object the prompt needs, with a default for every missing field.

    tools/jiraClient.js
    // Recursively flatten Atlassian Document Format (ADF) into plain text.
    function flattenAdf(node) {
      if (!node) return '';
      if (typeof node === 'string') return node;
      if (Array.isArray(node)) return node.map(flattenAdf).join('');
    
      let text = '';
      if (node.type === 'text' && typeof node.text === 'string') text += node.text;
      if (node.content) text += flattenAdf(node.content);
    
      // Add line breaks around block-level nodes so the text stays readable.
      if (['paragraph', 'heading', 'listItem', 'blockquote', 'bulletList', 'orderedList'].includes(node.type)) {
        text += '\n';
      }
      if (node.type === 'hardBreak' || node.type === 'rule') text += '\n';
      return text;
    }
    
    export function normalizeIssue(raw) {
      const f = raw.fields || {};
      const description = typeof f.description === 'string'
        ? f.description
        : flattenAdf(f.description).replace(/\n{3,}/g, '\n\n').trim();
    
      return {
        key: raw.key,
        summary: f.summary || '',
        description: description || '',
        issueType: f.issuetype?.name || 'Unknown',
        status: f.status?.name || 'Unknown',
        priority: f.priority?.name || 'Unspecified',
        components: (f.components || []).map((c) => c.name),
        labels: f.labels || [],
        fixVersions: (f.fixVersions || []).map((v) => v.name),
        reporter: f.reporter?.displayName || 'Unknown',
        assignee: f.assignee?.displayName || null,
      };
    }

    Ask Groq for JSON, and refuse anything else

    tools/groqClient.js
    const GROQ_URL = 'https://api.groq.com/openai/v1/chat/completions';
    export const GROQ_MODEL = 'openai/gpt-oss-120b';
    
    export async function groqChat(config, messages, { json = true, temperature = 0.3 } = {}) {
      if (!config.groqKey) throw new Error('Missing GROQ API key');
    
      const body = { model: GROQ_MODEL, messages, temperature };
      if (json) body.response_format = { type: 'json_object' };
    
      const res = await fetch(GROQ_URL, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${config.groqKey}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(body),
      });
    
      if (!res.ok) {
        const errText = await res.text();
        throw new Error(`GROQ ${res.status}: ${errText.slice(0, 300)}`);
      }
    
      const data = await res.json();
      const content = data.choices?.[0]?.message?.content || '';
      if (!json) return content;
    
      try {
        return JSON.parse(content);
      } catch {
        throw new Error('GROQ did not return valid JSON');
      }
    }

    The proxy route

    mergeConfig lets non-empty Settings values override .env; any error thrown by a tool becomes a 500 with { error }, which the UI shows as the message.

    server.js
    // UI-provided non-empty values override .env defaults.
    function mergeConfig(body = {}) {
      const env = envConfig();
      const c = body.config || {};
      return {
        jiraUrl: (c.jiraUrl || '').trim() || env.jiraUrl,
        jiraEmail: (c.jiraEmail || '').trim() || env.jiraEmail,
        jiraToken: (c.jiraToken || '').trim() || env.jiraToken,
        groqKey: (c.groqKey || '').trim() || env.groqKey,
      };
    }
    
    // Non-secret config presence, so the UI can prefill + warn.
    app.get('/api/config', (_req, res) => {
      const env = envConfig();
      res.json({
        jiraUrl: env.jiraUrl,
        jiraEmail: env.jiraEmail,
        hasJiraToken: Boolean(env.jiraToken),
        hasGroqKey: Boolean(env.groqKey),
      });
    });
    
    app.post('/api/generate', async (req, res) => {
      try {
        const jiraId = (req.body?.jiraId || '').trim();
        if (!jiraId) return res.status(400).json({ error: 'Missing jiraId' });
    
        const config = mergeConfig(req.body);
        const issue = await fetchIssue(config, jiraId);
        const plan = await generateTestPlan(config, issue);
        const markdown = renderMarkdown(plan, issue);
    
        res.json({ issue, plan, markdown });
      } catch (err) {
        res.status(500).json({ error: err.message });
      }
    });

    05Set up and run

    Node.js 18 or newer, a Jira Cloud account with an API token, and a free Groq key. Put the values in .env or type them into the Settings tab (blank Settings fields fall back to .env).

    terminal
    cd chapter_03_BLAST_FW_JIRA_AI_AGENT
    cp .env.sample .env        # then fill GROQ_KEY, JIRA_EMAIL, JIRA_API_TOKEN, JIRA_URL
    npm install
    npm run handshake          # Link check, defaults to VWO-48
    npm run dev                # SERVER on 8787 + CLIENT on 5173, open http://localhost:5173

    The variables, by name: GROQ_KEY, JIRA_EMAIL, JIRA_API_TOKEN (JIRA_TOKEN also works), JIRA_URL (for example https://your-site.atlassian.net), and optionally PORT (default 8787). The README links to the Atlassian API-token page and console.groq.com/keys.

    The handshake. It prints a Jira section with PASS: plus the ticket key and summary, a GROQ section with PASS: {"ok":true}, then LINK OK and exits 0. If either side fails it prints FAIL: with the error and ends with LINK BROKEN and exit code 1. In the recorded run, VWO-48 came back as "Shopping cart total shows $0.00 after applying discount code".

    terminal
    npm run build               # vite build -> dist/
    npm start                   # NODE_ENV=production node server.js -> http://localhost:8787

    Deploy to your own Vercel project. api/config.js, api/generate.js and api/save.js replace the Express routes; set the four variables in the Vercel dashboard.

    terminal
    npx vercel link --yes --project <your-project-name>
    npx vercel deploy --prod
    Only type real credentials into a deployment you control. The Settings tab keeps your Jira token and Groq key in the browser (localStorage, key blast.jira.config) and sends them in the body of every POST /api/generate to whichever server hosts the app.

    06What to watch

    • CORS is the reason for the proxy. A browser-only React app cannot call Jira Cloud REST. The proxy also keeps the token off the client, which matters more.
    • Descriptions are ADF, not text. REST v3 returns nested JSON. Without flattenAdf the prompt would contain [object Object]. Exotic nodes such as tables degrade to plain text (findings.md).
    • Ask only for the fields you render. The ?fields= filter keeps comments, history and attachments out of the prompt, which also keeps people's names and long threads away from the model.
    • The model can still be wrong. JSON mode guarantees valid JSON, not correct content. The rendered footer says it: review before use.
    • Express is pinned to v4. The production catch-all uses the regex /^(?!\/api).*/; Express 5 breaks app.get('*') (LLM.md, maintenance log).
    • Save to server is local only. On Vercel, /api/save returns 501 "Saving to server is disabled on serverless. Use Download .md." because the file system is read-only.
    • npm start uses POSIX syntax. NODE_ENV=production node server.js does not work in Windows cmd; use Git Bash or WSL, or set the variable separately.
    • Free tiers rate-limit. A Groq 429 comes back as GROQ 429: ... in the UI, which is why the SOP says to surface API errors instead of hiding them.