This website uses cookies

Read our Privacy policy and Terms of use for more information.

What we are building

By the end of this tutorial you will have a working Amazon Q Business application that a coworker could open in a browser, log into with their corporate identity, and ask: "What is our laptop refresh policy, and how many days of leave do I have left?" The first half of that question is answered from documents you indexed out of an S3 bucket. The second half is answered by a custom plugin that calls a small HTTP API you stand up, in real time, with the logged-in user's identity attached.

That split is the whole point of Q Business, and it is the thing most demos skip. A retrieval-augmented chatbot reads documents. Q Business reads documents through connectors and takes actions through plugins, both behind a single identity-aware chat surface. The interesting engineering is not the chat box. It is that an end user's question fans out to an index you control and an action you wrote, and the answer comes back grounded and permission-aware without you writing any orchestration code.

We will build the application, index, retriever, and S3 data source entirely from boto3, because that path is reproducible and reviewable in a way that clicking through the console is not. The Confluence connector and the custom plugin involve OAuth secrets, so we will create those in the console where the secret handling is safest, and I will show you the exact create_plugin call if you would rather script it. The finished artifact is a Python setup script plus a one-page OpenAPI schema you can push to GitHub.

One design choice up front: Q Business now requires an identity provider on every new application. We will use IAM Identity Center, because it is the path that supports both the Confluence connector's user mapping and the plugin's per-user OAuth handshake. Anonymous applications exist, but they cannot do actions, so they are a dead end for this episode.

Prerequisites

You need an AWS account where you can create IAM roles and enable IAM Identity Center. Identity Center is an organization-level service, so if you are on a shared account, confirm you are allowed to enable it or that an instance already exists. A standalone (account-level) Identity Center instance is fine for this tutorial.

You need boto3 at 1.40 or newer (pip show boto3 to check), Python 3.10+, and credentials with permission to call qbusiness:*, sso:*, iam:CreateRole, and iam:PassRole. You need an S3 bucket in the same Region as your application with two or three sample documents in it (a PDF or two and a Markdown file are enough). Pick a Region where Q Business is available, for example us-east-1 or us-west-2.

For the custom plugin you need somewhere to run a tiny HTTP endpoint that returns JSON. Any public HTTPS URL works. A Lambda behind a Function URL, an API Gateway, or even a throwaway endpoint you already own is fine. The plugin will not work against localhost, because Q Business calls it from the AWS side.

For the Confluence half you need a Confluence Cloud site and an API token. If you do not have one, skip that section. The tutorial stands on its own with just S3 plus the custom plugin, and I will mark the Confluence step as optional.

Assumed knowledge: you can read Python, you have created an IAM role before, and you understand what an OpenAPI schema is. You do not need prior Q Business experience.

Setup

Set your Region and confirm boto3 can reach the service. The Q Business control plane client is qbusiness.

pip install --upgrade "boto3>=1.40"
export AWS_REGION=us-east-1
export Q_BUCKET=my-qbusiness-docs-$(date +%s)
aws s3 mb "s3://$Q_BUCKET" --region "$AWS_REGION"
aws s3 cp ./sample-docs/ "s3://$Q_BUCKET/docs/" --recursive

Enable IAM Identity Center if it is not already on, and grab its instance ARN. This ARN is the one input every later call depends on.

aws sso-admin list-instances --region "$AWS_REGION" \
  --query "Instances[0].InstanceArn" --output text

If that returns None, open the IAM Identity Center console once and click Enable, then run the command again. Save the value:

export IDC_ARN=arn:aws:sso:::instance/ssoins-xxxxxxxxxxxx

Smoke test that your control plane client works before writing real code:

import boto3, os
q = boto3.client("qbusiness", region_name=os.environ["AWS_REGION"])
print(q.list_applications().get("applications", []))

An empty list is success. An AccessDeniedException here means your credentials are missing qbusiness:ListApplications, and you should fix that before going further. Do not continue past a failing smoke test, because every step below assumes this client is healthy.

Step 1: Create the application and wire it to Identity Center

The application is the top-level container. It holds the index, the data sources, the plugins, and the web experience. Creating it requires an IAM role that Q Business assumes to talk to CloudWatch and Identity Center, plus the identityType and the Identity Center ARN.

import boto3, os, json

REGION = os.environ["AWS_REGION"]
IDC_ARN = os.environ["IDC_ARN"]
q = boto3.client("qbusiness", region_name=REGION)
iam = boto3.client("iam")

app = q.create_application(
    displayName="company-assistant",
    identityType="AWS_IAM_IDC",
    identityCenterInstanceArn=IDC_ARN,
    description="S3 + Confluence + custom action demo",
)
APP_ID = app["applicationId"]
print("applicationId:", APP_ID)

identityType="AWS_IAM_IDC" is the key. Since April 2024 every new Q Business application must declare an identity source, and AWS_IAM_IDC ties user access to IAM Identity Center. The other values (AWS_IAM_IDP_SAML, AWS_IAM_IDP_OIDC, AWS_QUICKSIGHT_IDP, ANONYMOUS) exist for different deployment shapes, but Identity Center is what makes the connector and plugin user-mapping work, so we use it.

Notice we did not pass roleArn above. Q Business can run without an application-level role for the basic index path, but the data source will need its own role in Step 3. Keep the APP_ID printed value; everything else hangs off it. If this call fails with ValidationException: identityCenterInstanceArn is required, your IDC_ARN is empty, which means Identity Center is not enabled in this Region yet.

Step 2: Create the native index and retriever

The index is where your documents live after ingestion. The retriever is the component the chat surface queries. For a native index you create both; you do not need Amazon Kendra.

idx = q.create_index(
    applicationId=APP_ID,
    displayName="company-index",
    type="STARTER",            # STARTER for dev, ENTERPRISE for prod
    capacityConfiguration={"units": 1},
)
INDEX_ID = idx["indexId"]

# Wait until the index is ACTIVE before attaching a retriever
import time
while True:
    state = q.get_index(applicationId=APP_ID, indexId=INDEX_ID)["status"]
    print("index status:", state)
    if state in ("ACTIVE", "FAILED"):
        break
    time.sleep(15)

ret = q.create_retriever(
    applicationId=APP_ID,
    displayName="company-retriever",
    type="NATIVE_INDEX",
    configuration={"nativeIndexConfiguration": {"indexId": INDEX_ID}},
)
RETRIEVER_ID = ret["retrieverId"]
print("retrieverId:", RETRIEVER_ID)

Two things matter here. First, type="STARTER" gives you a cheaper, lower-capacity index suited to a demo; switch to ENTERPRISE only when you need the higher document ceiling and the extra retrieval features. Second, the index is not usable the instant create_index returns. It transitions through CREATING to ACTIVE, and attaching a retriever or a data source to a non-active index throws. The poll loop above is not optional padding; it is the difference between a script that works and one that fails intermittently depending on how fast the control plane is that afternoon.

One capacity unit on a starter index is enough for a few thousand documents. You are billed per index unit per hour whether or not you query it, which is the line item you will most want to delete at the end.

Step 3: Connect the S3 data source

The data source tells Q Business where to pull documents and how often. It needs an IAM role that Q Business assumes to read your bucket and write into the index with BatchPutDocument. Create that role first.

trust = {
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"Service": "qbusiness.amazonaws.com"},
    "Action": "sts:AssumeRole",
  }],
}
role = iam.create_role(
    RoleName="QBusinessS3DataSourceRole",
    AssumeRolePolicyDocument=json.dumps(trust),
)
ROLE_ARN = role["Role"]["Arn"]

bucket = os.environ["Q_BUCKET"]
iam.put_role_policy(
    RoleName="QBusinessS3DataSourceRole",
    PolicyName="s3-read-and-index-put",
    PolicyDocument=json.dumps({
      "Version": "2012-10-17",
      "Statement": [
        {"Effect": "Allow", "Action": ["s3:GetObject", "s3:ListBucket"],
         "Resource": [f"arn:aws:s3:::{bucket}", f"arn:aws:s3:::{bucket}/*"]},
        {"Effect": "Allow", "Action": ["qbusiness:BatchPutDocument", "qbusiness:BatchDeleteDocument"],
         "Resource": f"arn:aws:qbusiness:{REGION}:*:application/{APP_ID}/index/{INDEX_ID}"},
      ],
    }),
)

Now create the data source pointed at the docs/ prefix and kick off a sync.

ds = q.create_data_source(
    applicationId=APP_ID,
    indexId=INDEX_ID,
    displayName="s3-docs",
    roleArn=ROLE_ARN,
    configuration={
      "type": "S3",
      "version": "1.0.0",
      "syncMode": "FULL_CRAWL",
      "connectionConfiguration": {
        "repositoryEndpointMetadata": {"BucketName": bucket}
      },
      "repositoryConfigurations": {
        "document": {
          "fieldMappings": [
            {"indexFieldName": "s3_document_id",
             "dataSourceFieldName": "s3_document_id",
             "indexFieldType": "STRING"}
          ]
        }
      },
      "additionalProperties": {"inclusionPrefixes": ["docs/"]},
    },
)
DS_ID = ds["dataSourceId"]
q.start_data_source_sync_job(applicationId=APP_ID, indexId=INDEX_ID, dataSourceId=DS_ID)

The two permissions that trip people up are on the index side, not the bucket side. The data source role must be allowed to call BatchPutDocument and BatchDeleteDocument on the specific index ARN, because that is how the connector writes parsed documents in. Grant s3:GetObject but forget the index actions and the sync job fails with an access-denied error that points at the bucket, which sends you debugging the wrong half. The inclusionPrefixes keeps the crawl scoped to docs/ so you do not accidentally index the whole bucket.

The sync runs asynchronously. A few sample files index in a minute or two; thousands take longer. You can poll list_data_source_sync_jobs to watch the status move to SUCCEEDED.

Step 4 (optional): Add the Confluence connector

If you have Confluence Cloud, this is where the assistant gets a second source. The connector needs an API token stored in Secrets Manager and its own data source. The console handles the secret creation cleanly, so I recommend doing this one there: open your application, choose Data sources, Add data source, Confluence (Cloud), and supply your site URL, admin email, and API token. Q Business stores the token in Secrets Manager and creates the user-mapping it needs from Identity Center.

If you prefer the API, the call is the same create_data_source shape with "type": "CONFLUENCEV2", a connectionConfiguration carrying your hostUrl, and a secretArn referencing a Secrets Manager secret that holds your confluenceAppKey and token. The user-mapping is what makes Confluence results permission-aware: a user only sees pages their own Confluence account can see, enforced through Identity Center. That is the feature you are buying over a plain RAG pipeline, and it is why we did not take the anonymous-application shortcut in Step 1.

Skip this step entirely if you do not have a Confluence site. Nothing downstream depends on it.

Step 5: Write the custom plugin and its action

This is the part that turns a search box into an assistant that does things. A custom plugin is an OpenAPI v3 schema describing one or more operations (up to eight per plugin), plus an auth configuration. Q Business reads the schema, and when a user's question matches an operation's description, it calls your API in real time and folds the result into the answer.

Here is a one-operation schema for a leave-balance lookup. Save it as leave-plugin.json. Replace the server URL with your endpoint.

{
  "openapi": "3.0.1",
  "info": {"title": "HR Leave API", "version": "1.0.0"},
  "servers": [{"url": "https://your-endpoint.example.com"}],
  "paths": {
    "/leave-balance": {
      "get": {
        "operationId": "getLeaveBalance",
        "description": "Get the number of remaining paid leave days for the signed-in employee.",
        "responses": {
          "200": {
            "description": "Remaining leave",
            "content": {"application/json": {"schema": {
              "type": "object",
              "properties": {"remainingDays": {"type": "integer"}}
            }}}
          }
        }
      }
    }
  }
}

The description field is not documentation. It is the prompt Q Business uses to decide when to call this operation, so write it the way you would write a tool description for an agent: specific, unambiguous, and naming the user-facing intent. "Get leave balance" is too thin. "Get the number of remaining paid leave days for the signed-in employee" tells the model exactly when to fire. AWS publishes a best-practices guide for these schemas, and the single most useful rule in it is to make operation and parameter descriptions verbose and intent-named.

Create the plugin with create_plugin. For a demo endpoint that does its own auth, noAuthConfiguration is simplest; production plugins use oAuth2ClientCredentialConfiguration with a Secrets Manager secret so each user's identity flows through.

with open("leave-plugin.json") as f:
    schema = f.read()

plugin = q.create_plugin(
    applicationId=APP_ID,
    displayName="hr-leave",
    type="CUSTOM",
    serverUrl="https://your-endpoint.example.com",
    customPluginConfiguration={
        "description": "Look up remaining paid leave for the signed-in employee.",
        "apiSchemaType": "OPEN_API_V3",
        "apiSchema": {"payload": schema},
    },
    authConfiguration={"noAuthConfiguration": {}},
)
print("pluginId:", plugin["pluginId"])

The apiSchema accepts either an inline payload string, as above, or an s3 pointer with a bucket and key if your schema lives in version control and you sync it to S3. Inline is fine for one operation; switch to the S3 form once the schema grows and you want it reviewed in pull requests. Once the plugin exists, Q Business will offer it in the chat experience, and a user asking about their leave triggers a real call to your endpoint.

Verify it works

You have two things to verify: that documents are searchable, and that the action fires.

For search, create a web experience and open it, or test retrieval directly from the API without a browser. The headless check is faster:

sso = boto3.client("sso-oidc")  # only needed for real user tokens
qr = boto3.client("qbusiness", region_name=REGION)
resp = qr.chat_sync(
    applicationId=APP_ID,
    userId="[email protected]",
    userMessage="What is our laptop refresh policy?",
)
print(resp["systemMessage"])
for src in resp.get("sourceAttributions", []):
    print(" source:", src.get("title"))

A successful run prints an answer synthesized from your indexed documents and a sourceAttributions list naming the S3 files it pulled from. If sourceAttributions is empty but you get a generic answer, your sync has not finished or indexed nothing; re-check the sync job status from Step 3.

For the action, ask the assistant a question that matches your plugin's operation description, for example "How many leave days do I have left?" In the web experience the assistant shows a plugin authorization step on first use, then calls your endpoint and reports the number. If you are scripting, the same chat_sync call with that message returns an actionReview payload describing the operation it wants to run. The contract is simple: documents produce sourceAttributions, actions produce actionReview. Seeing the right one for the right question means both halves are wired.

When it breaks

If create_application throws ValidationException about the identity center ARN, Identity Center is not enabled in your Region, or your IDC_ARN env var is stale. Re-run the list-instances command and confirm it returns a real ARN, not None.

If the S3 sync job ends in FAILED with an access-denied message, the data source role is almost always missing the qbusiness:BatchPutDocument and BatchDeleteDocument permissions on the index ARN. The error text mentions the bucket, but the bucket read permission is rarely the real problem; the index write permission is. Re-read Step 3's policy and confirm the index ARN matches your actual INDEX_ID.

If chat_sync returns answers with no source attributions, the index is empty. Either the sync has not reached SUCCEEDED yet, or your inclusionPrefixes does not match where your files actually live. List the sync job and check metrics for documents added; zero added means the prefix is wrong.

If the plugin never fires no matter how you phrase the question, the operation description is too vague for Q Business to match intent. Rewrite it to name the exact user-facing action and the entity it returns. This is the same failure mode as a badly described agent tool, and the fix is the same: be specific.

If create_index succeeds but create_retriever throws, you attached the retriever before the index reached ACTIVE. Add or lengthen the poll loop from Step 2.

Where to take it next

First, make the plugin per-user. Swap noAuthConfiguration for oAuth2ClientCredentialConfiguration with a Secrets Manager secret holding your client ID and secret, so the leave lookup runs as the signed-in employee rather than a shared service identity. That is the difference between a toy and something HR would actually approve.

Second, add a second operation to the same schema, for example a submitLeaveRequest POST, so the assistant can both read and write. You get up to eight operations per plugin; keep each description sharp.

Third, layer Guardrails from episode 3 over this application so the assistant refuses to answer outside its document scope and redacts PII before it ever reaches a response. An assistant that takes actions over real employee data is exactly where a guardrail earns its keep.

Cleanup

Q Business bills you for the index per hour and for any user subscriptions you assigned, so tear this down when you are done. Delete in reverse order: plugin, data sources, retriever, index, application.

q.delete_plugin(applicationId=APP_ID, pluginId=plugin["pluginId"])
q.delete_data_source(applicationId=APP_ID, indexId=INDEX_ID, dataSourceId=DS_ID)
q.delete_retriever(applicationId=APP_ID, retrieverId=RETRIEVER_ID)
q.delete_index(applicationId=APP_ID, indexId=INDEX_ID)
q.delete_application(applicationId=APP_ID)
iam.delete_role_policy(RoleName="QBusinessS3DataSourceRole", PolicyName="s3-read-and-index-put")
iam.delete_role(RoleName="QBusinessS3DataSourceRole")

Then empty and remove the bucket if it was throwaway: aws s3 rb "s3://$Q_BUCKET" --force. If you assigned any users a Lite or Pro subscription in the console, remove them too, because the per-user charge accrues monthly independent of usage.

Rough cost of following this tutorial for an afternoon: one starter index unit runs about $0.011 per hour, so a few hours is well under a dollar. The S3 storage and chat_sync calls round to nothing at this scale. The only line item that bites is a forgotten user subscription at $3 (Lite) or $20 (Pro) per user per month, which is why the cleanup step calls it out explicitly. Delete the application the same day and you will spend pennies.

Sources

Keep Reading